From 64ea8b6dd19cfee326f2a4667c71dc4e25261c81 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Sun, 14 Jun 2026 20:15:37 +0900 Subject: [PATCH] security hardening UI: permission model v2, route takeover, remove Stripe/Discord - 5-level permission model (1=Admin..5=Disabled; read <5, manage <=2) via src/lib/constants/permissions.ts, mirroring the API; dropdowns/labels (en+ja), Disabled fallbacks 4->5, client-side @cropwatch.io filters removed (now enforced server-side) - rules/reports route takeover: template pages own /rules and /reports; old pre-template pages removed; 301 redirects from /rules-new & /reports-new; sidebar de-duplicated - refresh-on-expiry scheduler wired into dashboard, device detail, and location page (live Status column) - Stripe removed: /account/billing route, payments methods/constants in api.service.ts, Header billing entry, billing i18n keys - Discord removed: notifier/send-method option helpers, report communication-method fallback, i18n keys - bump @cropwatchdevelopment/cwui to 0.1.107 (published latest; matches lockfile) Verified: pnpm install --frozen-lockfile clean, svelte-check 0 errors, vitest 67/67, production build (adapter-vercel) clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- messages/en.json | 81 +- messages/ja.json | 81 +- package.json | 2 +- pnpm-lock.yaml | 10 +- src/lib/api/api.service.ts | 167 +--- src/lib/appContext.svelte.ts | 4 +- .../dashboard/DashboardCards.svelte | 102 ++- .../displays/RelayDisplay/RelayDisplay.svelte | 9 +- src/lib/constants/permissions.ts | 37 + src/lib/i18n/options.ts | 39 +- src/routes/+layout.server.ts | 6 +- src/routes/+layout.svelte | 4 +- src/routes/Header.svelte | 3 - src/routes/OverviewDrawer.svelte | 21 +- src/routes/Sidebar.svelte | 14 - src/routes/account/billing/+page.server.ts | 542 ------------ src/routes/account/billing/+page.svelte | 815 ------------------ .../locations/[location_id]/+page.server.ts | 3 +- .../locations/[location_id]/+page.svelte | 69 +- .../devices/[dev_eui]/+layout.server.ts | 7 +- .../devices/[dev_eui]/+page.svelte | 58 +- .../[dev_eui]/DeviceDashboardHeader.svelte | 5 +- .../DeviceOwnerPermissionsCard.svelte | 154 ++-- .../settings/device-settings.server.ts | 9 +- .../[location_id]/settings/+page.server.ts | 3 +- .../settings/LocationEditPermissions.svelte | 17 +- .../location-settings-actions.server.ts | 8 +- src/routes/reports-new/+page.svelte | 237 ----- .../reports-new/[...path]/+page.server.ts | 8 + src/routes/reports/+page.server.ts | 9 - src/routes/reports/+page.svelte | 281 +++--- src/routes/reports/DeleteReportDialog.svelte | 133 --- .../DeleteReportTemplateDialog.svelte | 0 .../ReportCadenceSection.svelte | 0 src/routes/reports/ReportHistoryDialog.svelte | 165 ---- .../ReportProcessingSchedulesSection.svelte | 0 .../ReportRecipientsSection.svelte | 0 .../ReportTemplateForm.svelte | 7 +- .../ViewReportHistoryDialog.svelte | 0 .../reports/[report_id]/edit/+page.server.ts | 144 ---- .../reports/[report_id]/edit/+page.svelte | 400 --------- .../edit/ReportCadenceSection.svelte | 84 -- .../ReportProcessingSchedulesSection.svelte | 201 ----- .../edit/ReportRecipientsSection.svelte | 137 --- .../edit/alert-points-editor-text.ts | 67 -- .../[report_id]/edit/data-pull-interval.ts | 69 -- .../[report_id]/edit/report-action.server.ts | 421 --------- .../reports/[report_id]/edit/report-form.ts | 542 ------------ .../[report_id]/edit/report-form.types.ts | 55 -- .../alert-points-editor-text.ts | 0 .../create/+page.server.ts | 0 .../create/+page.svelte | 2 +- .../data-pull-interval.ts | 0 .../edit/[id]/+page.server.ts | 0 .../edit/[id]/+page.svelte | 2 +- src/routes/reports/report-row.ts | 22 - .../report-template-form.ts | 0 src/routes/rules-new/+page.svelte | 254 ------ .../rules-new/[...path]/+page.server.ts | 8 + src/routes/rules-new/create/+page.server.ts | 24 - src/routes/rules-new/create/+page.svelte | 40 - .../rules-new/edit/[id]/+page.server.ts | 37 - src/routes/rules-new/edit/[id]/+page.svelte | 49 -- src/routes/rules/+page.server.ts | 9 - src/routes/rules/+page.svelte | 321 ++++--- src/routes/rules/DeleteRuleDialog.svelte | 64 -- .../DeleteRuleTemplateDialog.svelte | 0 src/routes/rules/RuleForm.svelte | 442 ---------- .../RuleTemplateForm.svelte | 4 +- .../RuleTemplateTest.svelte | 0 .../ViewRuleAlertHistory.svelte | 0 src/routes/rules/ViewRuleDialog.svelte | 188 ---- src/routes/rules/create/+page.server.ts | 15 +- src/routes/rules/create/+page.svelte | 45 +- src/routes/rules/edit/[id]/+page.server.ts | 47 +- src/routes/rules/edit/[id]/+page.svelte | 49 +- .../rule-template-alert-points.spec.ts | 0 .../rule-template-alert-points.ts | 0 78 files changed, 882 insertions(+), 5970 deletions(-) create mode 100644 src/lib/constants/permissions.ts delete mode 100644 src/routes/account/billing/+page.server.ts delete mode 100644 src/routes/account/billing/+page.svelte delete mode 100644 src/routes/reports-new/+page.svelte create mode 100644 src/routes/reports-new/[...path]/+page.server.ts delete mode 100644 src/routes/reports/+page.server.ts delete mode 100644 src/routes/reports/DeleteReportDialog.svelte rename src/routes/{reports-new => reports}/DeleteReportTemplateDialog.svelte (100%) rename src/routes/{reports-new => reports}/ReportCadenceSection.svelte (100%) delete mode 100644 src/routes/reports/ReportHistoryDialog.svelte rename src/routes/{reports-new => reports}/ReportProcessingSchedulesSection.svelte (100%) rename src/routes/{reports-new => reports}/ReportRecipientsSection.svelte (100%) rename src/routes/{reports-new => reports}/ReportTemplateForm.svelte (98%) rename src/routes/{reports-new => reports}/ViewReportHistoryDialog.svelte (100%) delete mode 100644 src/routes/reports/[report_id]/edit/+page.server.ts delete mode 100644 src/routes/reports/[report_id]/edit/+page.svelte delete mode 100644 src/routes/reports/[report_id]/edit/ReportCadenceSection.svelte delete mode 100644 src/routes/reports/[report_id]/edit/ReportProcessingSchedulesSection.svelte delete mode 100644 src/routes/reports/[report_id]/edit/ReportRecipientsSection.svelte delete mode 100644 src/routes/reports/[report_id]/edit/alert-points-editor-text.ts delete mode 100644 src/routes/reports/[report_id]/edit/data-pull-interval.ts delete mode 100644 src/routes/reports/[report_id]/edit/report-action.server.ts delete mode 100644 src/routes/reports/[report_id]/edit/report-form.ts delete mode 100644 src/routes/reports/[report_id]/edit/report-form.types.ts rename src/routes/{reports-new => reports}/alert-points-editor-text.ts (100%) rename src/routes/{reports-new => reports}/create/+page.server.ts (100%) rename src/routes/{reports-new => reports}/create/+page.svelte (97%) rename src/routes/{reports-new => reports}/data-pull-interval.ts (100%) rename src/routes/{reports-new => reports}/edit/[id]/+page.server.ts (100%) rename src/routes/{reports-new => reports}/edit/[id]/+page.svelte (98%) delete mode 100644 src/routes/reports/report-row.ts rename src/routes/{reports-new => reports}/report-template-form.ts (100%) delete mode 100644 src/routes/rules-new/+page.svelte create mode 100644 src/routes/rules-new/[...path]/+page.server.ts delete mode 100644 src/routes/rules-new/create/+page.server.ts delete mode 100644 src/routes/rules-new/create/+page.svelte delete mode 100644 src/routes/rules-new/edit/[id]/+page.server.ts delete mode 100644 src/routes/rules-new/edit/[id]/+page.svelte delete mode 100644 src/routes/rules/+page.server.ts delete mode 100644 src/routes/rules/DeleteRuleDialog.svelte rename src/routes/{rules-new => rules}/DeleteRuleTemplateDialog.svelte (100%) delete mode 100644 src/routes/rules/RuleForm.svelte rename src/routes/{rules-new => rules}/RuleTemplateForm.svelte (99%) rename src/routes/{rules-new => rules}/RuleTemplateTest.svelte (100%) rename src/routes/{rules-new => rules}/ViewRuleAlertHistory.svelte (100%) delete mode 100644 src/routes/rules/ViewRuleDialog.svelte rename src/routes/{rules-new => rules}/rule-template-alert-points.spec.ts (100%) rename src/routes/{rules-new => rules}/rule-template-alert-points.ts (100%) diff --git a/messages/en.json b/messages/en.json index f4b4188b..df2a380c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -41,7 +41,6 @@ "nav_rules": "Rules", "nav_reports": "Reports", "nav_profile": "Profile", - "nav_billing": "Billing", "nav_settings": "Settings", "nav_logout": "Logout", "header_privacy_mode": "Privacy Mode", @@ -278,6 +277,7 @@ "devices_device": "Device", "devices_add_device": "Add Device", "devices_device_name": "Device Name", + "devices_status": "Status", "devices_unnamed_device": "Unnamed Device", "rules_page_title": "Rules - CropWatch", "rules_configured_rules": "Configured Rules", @@ -557,7 +557,6 @@ "reports_create_operator_range": "is between", "reports_create_method_email": "Email", "reports_create_method_sms": "Text message", - "reports_create_method_discord": "Discord", "reports_schedule_card_title": "Data Processing Schedules", "reports_schedule_card_subtitle": "Configure what data to include or exclude based on time ranges.", "reports_schedule_card_copy": "Add one or more time windows to control which readings are included in this report.", @@ -599,13 +598,6 @@ "rule_operator_greater_or_equal": "Greater or equal (>=)", "rule_operator_less_or_equal": "Less or equal (<=)", "rule_operator_not_equal": "Not equal (!=)", - "rule_notifier_email": "Email", - "rule_notifier_sms": "SMS", - "rule_notifier_push": "Push Notification", - "rule_notifier_discord": "Discord", - "rule_send_email": "Email", - "rule_send_sms": "SMS", - "rule_send_both": "Both", "rule_subject_temperature": "Temperature (°C)", "rule_subject_humidity": "Humidity (%)", "rule_subject_co2": "CO₂ (ppm)", @@ -627,77 +619,6 @@ "validation_user_email_required": "User email is required.", "validation_valid_email_required": "Enter a valid email address.", "validation_correct_highlighted_fields": "Please correct the highlighted fields.", - "billing_account_status": "Account: {status}", - "billing_active_seats": "Active Seats", - "billing_active_seats_subtitle": "Currently billed subscriptions", - "billing_allow_discount_codes": "Allow discount codes", - "billing_allow_trial": "Allow trial", - "billing_api_warnings": "API Warnings", - "billing_api_warnings_subtitle": "Some endpoints returned errors", - "billing_archived": "Archived", - "billing_available_products": "Available Products", - "billing_available_products_subtitle": "Returned by payments API", - "billing_billed_interval": "Billed {interval}", - "billing_cancel_subscription_body": "Cancel {productName} ({id})?", - "billing_cancel_subscription_title": "Cancel Subscription", - "billing_cancel_subscription_warning": "This action calls the revoke endpoint and cannot be undone from this page.", - "billing_canceled": "Canceled", - "billing_canceled_subtitle": "Historical cancellations", - "billing_checkout_redirect_missing": "Checkout session was created, but no redirect URL was returned.", - "billing_checkout_session_created": "Checkout session created.", - "billing_checkout_session_failed": "Unable to create checkout session.", - "billing_checkout_subtitle": "Choose one or more products, then launch hosted checkout", - "billing_checkout_title": "Buy Seats & Devices", - "billing_clear_selection": "Clear Selection", - "billing_column_plan": "Plan", - "billing_column_price": "Price", - "billing_column_renews": "Renews", - "billing_column_started": "Started", - "billing_column_status": "Status", - "billing_confirm_cancel": "Confirm Cancel", - "billing_custom_pricing": "Custom pricing", - "billing_customer_email": "Customer email", - "billing_customer_email_placeholder": "jane@example.com", - "billing_customer_name": "Customer name", - "billing_customer_name_placeholder": "Jane Smith", - "billing_ended": "Ended", - "billing_error_products": "Products: {message}", - "billing_error_state": "State: {message}", - "billing_error_subscriptions": "Subscriptions: {message}", - "billing_heading": "Billing & Subscriptions", - "billing_include_archived_products": "Include archived products", - "billing_include_in_checkout": "Include in checkout", - "billing_keep_subscription": "Keep Subscription", - "billing_launch_checkout": "Launch Checkout", - "billing_load_products_failed": "Unable to load billing products.", - "billing_load_state_failed": "Unable to load subscription state.", - "billing_load_subscriptions_failed": "Unable to load subscriptions.", - "billing_no_product_description": "No description provided by API.", - "billing_no_products": "No products found for checkout.", - "billing_one_time": "One-time", - "billing_open_portal": "Open Billing Portal", - "billing_optional_checkout_settings": "Optional Checkout Settings", - "billing_portal_open_failed": "Unable to open billing portal.", - "billing_portal_opened": "Billing portal opened.", - "billing_portal_redirect_missing": "Portal session was created, but no redirect URL was returned.", - "billing_portal_session_created": "Billing portal session created.", - "billing_select_product_before_checkout": "Select at least one product before checkout.", - "billing_selected_count": "{count} selected", - "billing_status_active": "Active", - "billing_status_canceled": "Canceled", - "billing_status_past_due": "Past due", - "billing_status_trial": "Trial", - "billing_status_unknown": "Unknown", - "billing_status_unpaid": "Unpaid", - "billing_subscription_cancel_failed": "Unable to cancel subscription.", - "billing_subscription_canceled": "Subscription canceled successfully.", - "billing_subscription_id_required": "Subscription id is required.", - "billing_subscriptions_subtitle": "Current and historical subscription records", - "billing_subscriptions_title": "Subscriptions", - "billing_subtitle": "Manage plan purchases, active subscriptions, and customer portal access.", - "billing_trial_plans": "Trial Plans", - "billing_trial_plans_subtitle": "In trial period", - "billing_unknown_plan": "Unknown plan", "common_location": "Location", "common_weather": "Weather", "dashboard_active_alert_alt": "Active alert", diff --git a/messages/ja.json b/messages/ja.json index d2b15787..30f4e322 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -41,7 +41,6 @@ "nav_rules": "ルール", "nav_reports": "レポート", "nav_profile": "プロフィール", - "nav_billing": "請求", "nav_settings": "設定", "nav_logout": "ログアウト", "header_privacy_mode": "プライバシーモード", @@ -278,6 +277,7 @@ "devices_device": "デバイス", "devices_add_device": "デバイスを追加", "devices_device_name": "デバイス名", + "devices_status": "ステータス", "devices_unnamed_device": "名称未設定のデバイス", "rules_page_title": "ルール - CropWatch", "rules_configured_rules": "設定済みルール", @@ -557,7 +557,6 @@ "reports_create_operator_range": "の間", "reports_create_method_email": "メール", "reports_create_method_sms": "テキストメッセージ", - "reports_create_method_discord": "Discord", "reports_schedule_card_title": "データ処理スケジュール", "reports_schedule_card_subtitle": "時間範囲に基づいてレポートに含めるデータを設定します。", "reports_schedule_card_copy": "このレポートに含む計測値を制御するため、1つ以上の時間ウィンドウを追加してください。", @@ -599,13 +598,6 @@ "rule_operator_greater_or_equal": "以上 (>=)", "rule_operator_less_or_equal": "以下 (<=)", "rule_operator_not_equal": "等しくない (!=)", - "rule_notifier_email": "メール", - "rule_notifier_sms": "SMS", - "rule_notifier_push": "プッシュ通知", - "rule_notifier_discord": "Discord", - "rule_send_email": "メール", - "rule_send_sms": "SMS", - "rule_send_both": "両方", "rule_subject_temperature": "温度 (°C)", "rule_subject_humidity": "湿度 (%)", "rule_subject_co2": "CO₂ (ppm)", @@ -627,77 +619,6 @@ "validation_user_email_required": "ユーザーメールアドレスは必須です。", "validation_valid_email_required": "有効なメールアドレスを入力してください。", "validation_correct_highlighted_fields": "ハイライトされた項目を修正してください。", - "billing_account_status": "アカウント: {status}", - "billing_active_seats": "有効な契約数", - "billing_active_seats_subtitle": "現在課金中のサブスクリプション", - "billing_allow_discount_codes": "割引コードを許可", - "billing_allow_trial": "トライアルを許可", - "billing_api_warnings": "API 警告", - "billing_api_warnings_subtitle": "一部のエンドポイントでエラーが返されました", - "billing_archived": "アーカイブ済み", - "billing_available_products": "利用可能な商品", - "billing_available_products_subtitle": "決済 API から返された商品", - "billing_billed_interval": "{interval} ごとに請求", - "billing_cancel_subscription_body": "{productName} ({id}) を解約しますか?", - "billing_cancel_subscription_title": "サブスクリプションを解約", - "billing_cancel_subscription_warning": "この操作は revoke エンドポイントを呼び出し、このページからは元に戻せません。", - "billing_canceled": "解約済み", - "billing_canceled_subtitle": "過去の解約履歴", - "billing_checkout_redirect_missing": "チェックアウトセッションは作成されましたが、リダイレクト URL が返されませんでした。", - "billing_checkout_session_created": "チェックアウトセッションを作成しました。", - "billing_checkout_session_failed": "チェックアウトセッションを作成できませんでした。", - "billing_checkout_subtitle": "1 つ以上の商品を選択して、ホスト型チェックアウトを起動します", - "billing_checkout_title": "契約とデバイスを購入", - "billing_clear_selection": "選択をクリア", - "billing_column_plan": "プラン", - "billing_column_price": "価格", - "billing_column_renews": "更新日", - "billing_column_started": "開始日", - "billing_column_status": "状態", - "billing_confirm_cancel": "解約を確定", - "billing_custom_pricing": "個別見積もり", - "billing_customer_email": "顧客メールアドレス", - "billing_customer_email_placeholder": "jane@example.com", - "billing_customer_name": "顧客名", - "billing_customer_name_placeholder": "Jane Smith", - "billing_ended": "終了", - "billing_error_products": "商品: {message}", - "billing_error_state": "状態: {message}", - "billing_error_subscriptions": "サブスクリプション: {message}", - "billing_heading": "請求とサブスクリプション", - "billing_include_archived_products": "アーカイブ済み商品を含める", - "billing_include_in_checkout": "チェックアウトに含める", - "billing_keep_subscription": "継続する", - "billing_launch_checkout": "チェックアウトを開始", - "billing_load_products_failed": "請求商品の読み込みに失敗しました。", - "billing_load_state_failed": "サブスクリプション状態の読み込みに失敗しました。", - "billing_load_subscriptions_failed": "サブスクリプションの読み込みに失敗しました。", - "billing_no_product_description": "API から説明が返されませんでした。", - "billing_no_products": "チェックアウトできる商品が見つかりません。", - "billing_one_time": "単発購入", - "billing_open_portal": "請求ポータルを開く", - "billing_optional_checkout_settings": "任意のチェックアウト設定", - "billing_portal_open_failed": "請求ポータルを開けませんでした。", - "billing_portal_opened": "請求ポータルを開きました。", - "billing_portal_redirect_missing": "ポータルセッションは作成されましたが、リダイレクト URL が返されませんでした。", - "billing_portal_session_created": "請求ポータルセッションを作成しました。", - "billing_select_product_before_checkout": "チェックアウト前に少なくとも 1 つの商品を選択してください。", - "billing_selected_count": "{count} 件を選択中", - "billing_status_active": "有効", - "billing_status_canceled": "解約済み", - "billing_status_past_due": "支払い遅延", - "billing_status_trial": "トライアル", - "billing_status_unknown": "不明", - "billing_status_unpaid": "未払い", - "billing_subscription_cancel_failed": "サブスクリプションを解約できませんでした。", - "billing_subscription_canceled": "サブスクリプションを解約しました。", - "billing_subscription_id_required": "サブスクリプション ID は必須です。", - "billing_subscriptions_subtitle": "現在および過去のサブスクリプション記録", - "billing_subscriptions_title": "サブスクリプション", - "billing_subtitle": "プラン購入、アクティブなサブスクリプション、顧客ポータルへのアクセスを管理します。", - "billing_trial_plans": "トライアルプラン", - "billing_trial_plans_subtitle": "トライアル期間中", - "billing_unknown_plan": "不明なプラン", "common_location": "ロケーション", "common_weather": "天気", "dashboard_active_alert_alt": "アクティブなアラート", diff --git a/package.json b/package.json index c0dcb25e..103ad293 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ }, "packageManager": "pnpm@10.18.2+sha512.9fb969fa749b3ade6035e0f109f0b8a60b5d08a1a87fdf72e337da90dcc93336e2280ca4e44f2358a649b83c17959e9993e777c2080879f3801e6f0d999ad3dd", "dependencies": { - "@cropwatchdevelopment/cwui": "0.1.105", + "@cropwatchdevelopment/cwui": "0.1.107", "@supabase/supabase-js": "^2.98.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a43647a4..79277ca3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@cropwatchdevelopment/cwui': - specifier: 0.1.105 - version: 0.1.105(svelte@5.53.0) + specifier: 0.1.107 + version: 0.1.107(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.105': - resolution: {integrity: sha512-hmN1d7aNy4D+NUYc6G5nSILI4UBFxwngocojzf3wtrPfecoFAgbHbMEuiQe/p/norhdkG5wGR9frckqkAB0wwA==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.105/fb228c70c83b370df92b60cf8de16ce687f161f9} + '@cropwatchdevelopment/cwui@0.1.107': + resolution: {integrity: sha512-09HVLBZxM+Q4NBNfIgLEurWlt4NDnkV/NPwvLP3Bm0xWH+zUIVoC1MpRdn/2EsEAIYoOIz4tHzhVVIbfigsODA==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.107/f942b24e41a617c9a85ad5e27c0e9cefa45d7ae9} peerDependencies: svelte: ^5.0.0 @@ -2147,7 +2147,7 @@ packages: snapshots: - '@cropwatchdevelopment/cwui@0.1.105(svelte@5.53.0)': + '@cropwatchdevelopment/cwui@0.1.107(svelte@5.53.0)': dependencies: svelte: 5.53.0 diff --git a/src/lib/api/api.service.ts b/src/lib/api/api.service.ts index 6f339a94..a8580d6b 100644 --- a/src/lib/api/api.service.ts +++ b/src/lib/api/api.service.ts @@ -1,10 +1,7 @@ import { env as publicEnv } from '$env/dynamic/public'; -import type { PdfFile } from '../interfaces/PdfFile.interface'; import type { CreateDeviceRequest, CreateLocationRequest, - CreateReportRequest, - CreateRuleRequest, DashboardLocationPage, DashboardPage, DashboardQuery, @@ -22,8 +19,6 @@ import type { LoginResponse, PaginatedResponse, PaginationQuery, - ReportDto, - ReportsQuery, ReportTemplateDto, ReportTemplateListQuery, ReportTemplateSaveRequest, @@ -31,13 +26,11 @@ import type { ReportTemplateHistoryItemDto, CommunicationMethodDto, RuleActionTypeDto, - RuleDto, RuleFormContextDto, RuleTemplateDto, RuleTemplateListQuery, RuleTemplateSaveRequest, RuleTriggerLogDto, - RulesQuery, SensorTimeSeriesPoint, TimeRangeQuery, TrafficDataPoint, @@ -48,8 +41,6 @@ import type { UpdateRelayRequest, UpdateLocationRequest, UpdateLocationOwnerRequest, - UpdateReportRequest, - UpdateRuleRequest, WaterDataPoint, GatewayDto } from './api.dtos'; @@ -143,25 +134,11 @@ const LOCATION_PERMISSION_UPDATE_PERMISSION_LEVEL_ENDPOINT = '/locations/{id}/pe const POWER_ENDPOINT = '/power/{id}'; const RELAY_ENDPOINT = '/relay/{dev_eui}'; const RELAY_PULSE_ENDPOINT = '/relay/{dev_eui}/pulse'; -const PAYMENTS_PRODUCTS_ENDPOINT = '/payments/products'; -const PAYMENTS_SUBSCRIPTIONS_ENDPOINT = '/payments/subscriptions'; -const PAYMENTS_SUBSCRIPTION_STATE_ENDPOINT = '/payments/subscriptions/state'; -const PAYMENTS_SUBSCRIPTIONS_CHECKOUT_ENDPOINT = '/payments/subscriptions/checkout'; -const PAYMENTS_SUBSCRIPTIONS_PORTAL_ENDPOINT = '/payments/subscriptions/portal'; -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 RULE_TEMPLATE_FORM_CONTEXT_ENDPOINT = `${RULE_TEMPLATES_ENDPOINT}/form-context`; -const TRIGGERED_RULES_BASE_ENDPOINT = - publicEnv.PUBLIC_TRIGGERED_RULES_ENDPOINT ?? `${RULES_BASE_ENDPOINT}/triggered`; -const TRIGGERED_RULES_COUNT_ENDPOINT = - publicEnv.PUBLIC_TRIGGERED_RULES_ENDPOINT_COUNT ?? `${TRIGGERED_RULES_BASE_ENDPOINT}/count`; -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 TRIGGERED_RULES_BASE_ENDPOINT = `${RULE_TEMPLATES_ENDPOINT}/triggered`; +const TRIGGERED_RULES_COUNT_ENDPOINT = `${TRIGGERED_RULES_BASE_ENDPOINT}/count`; const RULE_TEMPLATE_BY_ID_ENDPOINT = `${RULE_TEMPLATES_ENDPOINT}/{id}`; const RULE_TEMPLATE_HISTORY_ENDPOINT = `${RULE_TEMPLATES_ENDPOINT}/{id}/history`; const REPORT_TEMPLATES_ENDPOINT = publicEnv.PUBLIC_REPORT_TEMPLATES_ENDPOINT ?? '/reports-new'; @@ -1073,24 +1050,8 @@ export class ApiService { ); } - public createRule(payload: CreateRuleRequest): Promise { - return this.request(RULES_BASE_ENDPOINT, { - method: 'POST', - body: payload - }); - } - - public getRules(query: RulesQuery = {}): Promise { - return this.request(RULES_BASE_ENDPOINT, { - method: 'GET', - query: { - name: query.name - } - }); - } - - public getTriggeredRules(): Promise { - return this.request(TRIGGERED_RULES_BASE_ENDPOINT, { + public getTriggeredRules(): Promise { + return this.request(TRIGGERED_RULES_BASE_ENDPOINT, { method: 'GET' }); } @@ -1101,25 +1062,6 @@ export class ApiService { }); } - public getRule(id: number): Promise { - return this.request(replacePathParams(RULE_BY_ID_ENDPOINT, { id }), { - method: 'GET' - }); - } - - public updateRule(id: number, payload: UpdateRuleRequest): Promise { - return this.request(replacePathParams(RULE_BY_ID_ENDPOINT, { id }), { - method: 'PATCH', - body: payload - }); - } - - public deleteRule(ruleGroupId: string): Promise { - return this.request(replacePathParams(RULE_BY_ID_ENDPOINT, { id: ruleGroupId }), { - method: 'DELETE' - }); - } - public getRuleTemplates( query: RuleTemplateListQuery = {}, options: ApiMethodOptions = {} @@ -1313,112 +1255,11 @@ export class ApiService { ); } - public createReport(payload: CreateReportRequest): Promise { - return this.request(REPORTS_BASE_ENDPOINT, { - method: 'POST', - body: payload - }); - } - - public getReports(query: ReportsQuery = {}): Promise { - return this.request(REPORTS_BASE_ENDPOINT, { - method: 'GET', - query: { - name: query.name - } - }); - } - - public getReportHistory(devEui: string): Promise { - return this.request( - replacePathParams(REPORT_HISTORY_ENDPOINT, { dev_eui: devEui }), - { - method: 'GET' - } - ); - } - - public getReportDownloadUrl( - dev_eui: string, - report_id: string, - reportName: string - ): Promise> { - return this.request>( - replacePathParams(REPORT_DOWNLOAD_ENDPOINT, { dev_eui, report_id, reportName }), - { - method: 'GET' - } - ); - } - - public getReport(reportId: string): Promise { - return this.request( - replacePathParams(REPORT_BY_REPORT_ID_ENDPOINT, { report_id: reportId }), - { - method: 'GET' - } - ); - } - - public updateReport(reportId: string, payload: UpdateReportRequest): Promise { - return this.request( - replacePathParams(REPORT_BY_REPORT_ID_ENDPOINT, { report_id: reportId }), - { - method: 'PATCH', - body: payload - } - ); - } - - public deleteReport(reportId: string): Promise { - return this.request( - replacePathParams(REPORT_BY_REPORT_ID_ENDPOINT, { report_id: reportId }), - { - method: 'DELETE' - } - ); - } - - public getPaymentsProducts(): Promise { - return this.request(PAYMENTS_PRODUCTS_ENDPOINT, { method: 'GET' }); - } - - public getPaymentsSubscriptions(): Promise { - return this.request(PAYMENTS_SUBSCRIPTIONS_ENDPOINT, { method: 'GET' }); - } - - public getPaymentsSubscriptionState(): Promise { - return this.request(PAYMENTS_SUBSCRIPTION_STATE_ENDPOINT, { method: 'GET' }); - } - - public createPaymentsCheckoutSession(payload: Record): Promise { - return this.request(PAYMENTS_SUBSCRIPTIONS_CHECKOUT_ENDPOINT, { - method: 'POST', - body: payload - }); - } - - public createPaymentsPortalSession(payload: Record): Promise { - return this.request(PAYMENTS_SUBSCRIPTIONS_PORTAL_ENDPOINT, { - method: 'POST', - body: payload - }); - } - public getGateways(): Promise { return this.request(GATEWAYS_ENDPOINT, { method: 'GET' }); } - - public cancelPaymentsSubscription(subscriptionId: string): Promise { - return this.request( - `${PAYMENTS_SUBSCRIPTIONS_ENDPOINT}/${encodeURIComponent(subscriptionId)}`, - { - method: 'DELETE' - } - ); - } } export * from './api.dtos'; diff --git a/src/lib/appContext.svelte.ts b/src/lib/appContext.svelte.ts index 985cf81b..e8ff02f4 100644 --- a/src/lib/appContext.svelte.ts +++ b/src/lib/appContext.svelte.ts @@ -5,7 +5,7 @@ import type { IDevice } from './interfaces/device.interface'; import type { IRule } from './interfaces/rule.interface'; import type { Profile } from './interfaces/profile.interface'; import { createCwAlarmScheduler } from '@cropwatchdevelopment/cwui'; -import type { RuleDto, TriggeredRulesCountResponse } from './api/api.service'; +import type { RuleTemplateDto, TriggeredRulesCountResponse } from './api/api.service'; import type { LocationDto } from './api/api.dtos'; const DEVICE_STALE_MINUTES = 10; @@ -23,7 +23,7 @@ export interface AppContext { deviceStatuses: { online: number; offline: number }; totalDeviceCount?: number; rules: IRule[]; - triggeredRules: RuleDto[]; + triggeredRules: RuleTemplateDto[]; triggeredRulesCount: number; staleDeviceIds: string[]; accessToken?: string; diff --git a/src/lib/components/dashboard/DashboardCards.svelte b/src/lib/components/dashboard/DashboardCards.svelte index b08e56c3..fd036277 100644 --- a/src/lib/components/dashboard/DashboardCards.svelte +++ b/src/lib/components/dashboard/DashboardCards.svelte @@ -6,11 +6,16 @@ diff --git a/src/lib/components/displays/RelayDisplay/RelayDisplay.svelte b/src/lib/components/displays/RelayDisplay/RelayDisplay.svelte index b6d4c90f..fe03f9c6 100644 --- a/src/lib/components/displays/RelayDisplay/RelayDisplay.svelte +++ b/src/lib/components/displays/RelayDisplay/RelayDisplay.svelte @@ -39,6 +39,7 @@ import { formatDateTime } from '$lib/i18n/format'; import type { DeviceDisplayProps } from '$lib/interfaces/deviceDisplay'; import { m } from '$lib/paraglide/messages.js'; + import { canManage } from '$lib/constants/permissions'; let { latestData, @@ -82,7 +83,7 @@ let lastUpdate = $derived(latestRelayRow?.created_at ?? ''); let relayOperationsLocked = $derived(hasLockedRelayOperation()); let controlNotice = $derived.by(() => { - if (permissionLevel > 2) { + if (!canManage(permissionLevel)) { return m.devices_relay_controls_requires_permission(); } return ''; @@ -207,7 +208,7 @@ relayOperationsLocked || isRelaySubmittingAny(relay) || !authToken || - permissionLevel > 2 || + !canManage(permissionLevel) || !queueRelayCommand ) { return true; @@ -226,7 +227,7 @@ relayOperationsLocked || isRelaySubmittingAny(relay) || !authToken || - permissionLevel > 2 || + !canManage(permissionLevel) || !queueRelayCommand || parseTimedOnDurationSeconds() === null ) { @@ -427,7 +428,7 @@ max={MAX_RELAY_PULSE_DURATION_SECONDS} step={1} error={timedOnDurationError || undefined} - disabled={!authToken || permissionLevel > 2} + disabled={!authToken || !canManage(permissionLevel)} />
= MIN_PERMISSION_LEVEL && level <= MAX_PERMISSION_LEVEL + ); +} diff --git a/src/lib/i18n/options.ts b/src/lib/i18n/options.ts index fdd07562..ef79c299 100644 --- a/src/lib/i18n/options.ts +++ b/src/lib/i18n/options.ts @@ -1,22 +1,25 @@ import { m } from '$lib/paraglide/messages.js'; -export function getPermissionLevelOptions(viewerLabel?: string) { +export function getPermissionLevelOptions(userLabel?: string) { return [ { label: m.permission_level_admin(), value: '1' }, { label: m.permission_level_manager(), value: '2' }, - { label: viewerLabel ?? m.permission_level_user(), value: '3' }, - { label: m.permission_level_disabled(), value: '4' } + { label: userLabel ?? m.permission_level_user(), value: '3' }, + { label: m.permission_level_viewer(), value: '4' }, + { label: m.permission_level_disabled(), value: '5' } ]; } -export function getPermissionLevelLabel(value: string | number, viewerLabel?: string): string { +export function getPermissionLevelLabel(value: string | number, userLabel?: string): string { switch (String(value)) { case '1': return m.permission_level_admin(); case '2': return m.permission_level_manager(); case '3': - return viewerLabel ?? m.permission_level_user(); + return userLabel ?? m.permission_level_user(); + case '4': + return m.permission_level_viewer(); default: return m.permission_level_disabled(); } @@ -33,32 +36,6 @@ export function getRuleOperatorOptions() { ]; } -export function getRuleNotifierTypeOptions() { - return [ - { label: m.rule_notifier_email(), value: '1' }, - { label: m.rule_notifier_sms(), value: '2' }, - { label: m.rule_notifier_push(), value: '3' }, - { label: m.rule_notifier_discord(), value: '4' } - ]; -} - -export function getRuleSendMethodOptions() { - return [ - { label: m.rule_send_email(), value: 'email' }, - { label: m.rule_send_sms(), value: 'sms' }, - { label: m.rule_send_both(), value: 'both' } - ]; -} - -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/routes/+layout.server.ts b/src/routes/+layout.server.ts index 483ef6b5..2c589210 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,5 +1,5 @@ import { ApiService } from '$lib/api/api.service'; -import type { DeviceStatusSummary, RuleDto } from '$lib/api/api.dtos'; +import type { DeviceStatusSummary, RuleTemplateDto } from '$lib/api/api.dtos'; import type { LayoutServerLoad } from './$types'; const EMPTY_DEVICE_STATUSES: DeviceStatusSummary = { online: 0, offline: 0 }; @@ -23,7 +23,7 @@ function readTriggeredRulesCount(rawTriggeredRulesCount: unknown): number { async function loadOverviewData(apiServiceInstance: ApiService): Promise<{ deviceStatuses: DeviceStatusSummary; - triggeredRules: RuleDto[]; + triggeredRules: RuleTemplateDto[]; triggeredRulesCount: number; }> { const [deviceStatusesResult, triggeredRulesResult, triggeredRulesCountResult] = @@ -65,7 +65,7 @@ export const load: LayoutServerLoad = async ({ locals, fetch }) => { let profile; let overview = { deviceStatuses: EMPTY_DEVICE_STATUSES, - triggeredRules: [] as RuleDto[], + triggeredRules: [] as RuleTemplateDto[], triggeredRulesCount: 0 }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 43f8e2ac..23af5280 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -17,7 +17,7 @@ import OverviewDrawer from './OverviewDrawer.svelte'; import Sidebar from './Sidebar.svelte'; import { createAppContext, setAppContext } from '$lib/appContext.svelte'; - import type { DeviceStatusSummary, RuleDto } from '$lib/api/api.dtos'; + import type { DeviceStatusSummary, RuleTemplateDto } from '$lib/api/api.dtos'; import type { IJWT } from '$lib/interfaces/jwt.interface'; import type { LayoutProps } from './$types'; import Header from './Header.svelte'; @@ -38,7 +38,7 @@ profile?: Profile | undefined; overview?: { deviceStatuses: DeviceStatusSummary; - triggeredRules: RuleDto[]; + triggeredRules: RuleTemplateDto[]; triggeredRulesCount: number; }; } diff --git a/src/routes/Header.svelte b/src/routes/Header.svelte index 969a2e15..d90db339 100644 --- a/src/routes/Header.svelte +++ b/src/routes/Header.svelte @@ -19,7 +19,6 @@ const menuItems = $derived([ { id: 'profile', label: m.nav_profile() }, - { id: 'billing', label: m.nav_billing() }, { id: 'settings', label: m.nav_settings() }, { id: 'logout', label: m.nav_logout(), separator: true, danger: true } ]); @@ -73,8 +72,6 @@ goto(resolve('/account/profile')); } else if (event.id === 'settings') { goto(resolve('/settings')); - } else if (event.id === 'billing') { - goto(resolve('/account/billing')); } }} /> diff --git a/src/routes/OverviewDrawer.svelte b/src/routes/OverviewDrawer.svelte index 0b5beb42..4ee6572d 100644 --- a/src/routes/OverviewDrawer.svelte +++ b/src/routes/OverviewDrawer.svelte @@ -48,12 +48,21 @@ ]); 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() - })) + triggeredRules.map((rule) => { + // Triggered templates carry per-device state; show the most recent trigger time. + const lastTriggeredAt = rule.assignments + .map((assignment) => assignment.state?.lastTriggeredAt) + .filter((value): value is string => typeof value === 'string') + .sort() + .at(-1); + + return { + id: String(rule.id), + icon: '🔔', + name: rule.name, + reported: lastTriggeredAt ? formatDateTime(lastTriggeredAt) : m.common_not_available() + }; + }) ); diff --git a/src/routes/Sidebar.svelte b/src/routes/Sidebar.svelte index c2e72c9c..fd163578 100644 --- a/src/routes/Sidebar.svelte +++ b/src/routes/Sidebar.svelte @@ -62,13 +62,6 @@ icon: { path: RULES_ICON_PATH }, group: m.nav_group_info_management() }, - { - id: 'rules-new', - label: `${m.nav_rules()} (beta)`, - href: '/rules-new', - icon: { path: RULES_ICON_PATH }, - group: m.nav_group_info_management() - }, { id: 'reports', label: m.nav_reports(), @@ -76,13 +69,6 @@ icon: { path: REPORTS_ICON_PATH }, group: m.nav_group_info_management() }, - { - id: 'reports-new', - label: `${m.nav_reports()} (beta)`, - href: '/reports-new', - icon: { path: REPORTS_ICON_PATH }, - group: m.nav_group_info_management() - }, { id: 'gateways', label: m.nav_gateways(), diff --git a/src/routes/account/billing/+page.server.ts b/src/routes/account/billing/+page.server.ts deleted file mode 100644 index b543859b..00000000 --- a/src/routes/account/billing/+page.server.ts +++ /dev/null @@ -1,542 +0,0 @@ -import { ApiService, ApiServiceError } from '$lib/api/api.service'; -import { formatCurrency } from '$lib/i18n/format'; -import { m } from '$lib/paraglide/messages.js'; -import { buildLoginPath } from '$lib/utils/auth-redirect'; -import { fail, redirect } from '@sveltejs/kit'; -import type { Actions, PageServerLoad } from './$types'; - -type UnknownRecord = Record; - -type BillingProduct = { - id: string; - name: string; - description: string; - priceLabel: string; - billingLabel: string; - active: boolean; -}; - -type BillingSubscription = { - id: string; - status: string; - productName: string; - startedAt: string | null; - renewsAt: string | null; - canceledAt: string | null; - amountLabel: string; -}; - -type BillingState = { - status: string; - hasActiveSubscription: boolean; - customerId: string | null; - activeSubscriptionCount: number; - trialSubscriptionCount: number; - cancelledSubscriptionCount: number; -}; - -type EndpointResult = { - data: unknown; - error: string | null; -}; - -const isRecord = (value: unknown): value is UnknownRecord => - typeof value === 'object' && value !== null && !Array.isArray(value); - -const asString = (value: unknown): string | null => { - if (typeof value === 'string') { - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; - } - if (typeof value === 'number' && Number.isFinite(value)) { - return String(value); - } - return null; -}; - -const asNumber = (value: unknown): number | null => { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string') { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : null; - } - return null; -}; - -const asBoolean = (value: unknown): boolean | null => { - 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 null; -}; - -const getPathValue = (record: UnknownRecord, path: string[]): unknown => { - let current: unknown = record; - for (const segment of path) { - if (!isRecord(current)) return undefined; - current = current[segment]; - } - return current; -}; - -const getPathFromUnknown = (value: unknown, path: string): unknown => { - const segments = path.split('.'); - let current: unknown = value; - for (const segment of segments) { - if (!isRecord(current)) return undefined; - current = current[segment]; - } - return current; -}; - -const toIsoStringOrNull = (value: unknown): string | null => { - if (typeof value === 'number' && Number.isFinite(value)) { - const fromNumber = new Date(value); - return Number.isNaN(fromNumber.getTime()) ? null : fromNumber.toISOString(); - } - const text = asString(value); - if (!text) return null; - const parsed = new Date(text); - return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); -}; - -const extractRecords = (payload: unknown, keys: string[]): UnknownRecord[] => { - if (Array.isArray(payload)) { - return payload.filter(isRecord); - } - if (!isRecord(payload)) { - return []; - } - - for (const key of keys) { - const value = getPathFromUnknown(payload, key); - if (Array.isArray(value)) { - const records = value.filter(isRecord); - if (records.length > 0) return records; - } - } - - return []; -}; - -const extractPriceRecord = (source: UnknownRecord): UnknownRecord | null => { - if (Array.isArray(source.prices)) { - const firstPrice = source.prices.find(isRecord); - if (firstPrice) return firstPrice; - } - if (isRecord(source.price)) return source.price; - if (isRecord(source.product_price)) return source.product_price; - return null; -}; - -const extractPriceLabel = (source: UnknownRecord): { priceLabel: string; billingLabel: string } => { - const price = extractPriceRecord(source); - const amountMinor = - asNumber(price?.price_amount) ?? - asNumber(price?.amount) ?? - asNumber(source.price_amount) ?? - asNumber(source.amount); - const currency = - asString(price?.price_currency) ?? - asString(price?.currency) ?? - asString(source.price_currency) ?? - asString(source.currency) ?? - 'USD'; - const interval = - asString(price?.recurring_interval) ?? - asString(price?.interval) ?? - asString(source.recurring_interval) ?? - asString(source.interval); - - const priceLabel = - amountMinor === null ? m.billing_custom_pricing() : formatCurrency(amountMinor / 100, currency); - const billingLabel = interval ? m.billing_billed_interval({ interval }) : m.billing_one_time(); - return { priceLabel, billingLabel }; -}; - -const normalizeProducts = (payload: unknown): BillingProduct[] => { - const items = extractRecords(payload, [ - 'items', - 'products', - 'data', - 'result', - 'result.items', - 'result.products', - 'data.items', - 'data.products' - ]); - return items.flatMap((item) => { - const base = isRecord(item.product) ? item.product : item; - const id = - asString(base.id) ?? - asString(base.product_id) ?? - asString(base.productId) ?? - asString(base.uuid) ?? - asString(item.id); - if (!id) return []; - - const name = - asString(base.name) ?? - asString(base.title) ?? - asString(base.display_name) ?? - asString(getPathValue(base, ['metadata', 'name'])) ?? - asString(getPathValue(item, ['metadata', 'name'])) ?? - id; - const description = - asString(base.description) ?? - asString(getPathValue(base, ['metadata', 'description'])) ?? - asString(getPathValue(item, ['metadata', 'description'])) ?? - ''; - const active = - (asBoolean(base.active) ?? asBoolean(item.active) ?? true) && - !(asBoolean(base.archived) ?? asBoolean(item.archived) ?? false) && - !(asBoolean(base.is_archived) ?? asBoolean(item.is_archived) ?? false); - const pricing = extractPriceLabel(isRecord(item.product) ? { ...base, ...item } : item); - - return [ - { - id, - name, - description, - priceLabel: pricing.priceLabel, - billingLabel: pricing.billingLabel, - active - } - ]; - }); -}; - -const normalizeSubscriptions = (payload: unknown): BillingSubscription[] => { - const items = extractRecords(payload, [ - 'items', - 'subscriptions', - 'data', - 'result', - 'result.items', - 'result.subscriptions', - 'data.items', - 'data.subscriptions' - ]); - return items.flatMap((item) => { - const id = asString(item.id) ?? asString(item.subscription_id) ?? asString(item.subscriptionId); - if (!id) return []; - - const status = - asString(item.status) ?? - asString(item.state) ?? - asString(item.subscription_status) ?? - m.billing_status_unknown(); - const productName = - asString(getPathValue(item, ['product', 'name'])) ?? - asString(getPathValue(item, ['product', 'display_name'])) ?? - asString(getPathValue(item, ['price', 'product', 'name'])) ?? - asString(getPathValue(item, ['product_price', 'product', 'name'])) ?? - asString(item.product_name) ?? - asString(item.productName) ?? - m.billing_unknown_plan(); - const startedAt = - toIsoStringOrNull(item.started_at) ?? - toIsoStringOrNull(item.start_date) ?? - toIsoStringOrNull(item.current_period_start); - const renewsAt = - toIsoStringOrNull(item.current_period_end) ?? - toIsoStringOrNull(item.renews_at) ?? - toIsoStringOrNull(item.ends_at); - const canceledAt = - toIsoStringOrNull(item.canceled_at) ?? - toIsoStringOrNull(item.cancel_at) ?? - toIsoStringOrNull(item.revoked_at); - const pricing = extractPriceLabel(item); - - return [ - { - id, - status, - productName, - startedAt, - renewsAt, - canceledAt, - amountLabel: pricing.priceLabel - } - ]; - }); -}; - -const normalizeState = (payload: unknown): BillingState => { - const state = isRecord(payload) ? payload : {}; - const activeSubscriptionCount = - asNumber(state.active_subscription_count) ?? asNumber(state.activeSubscriptionCount) ?? 0; - const trialSubscriptionCount = - asNumber(state.trial_subscription_count) ?? asNumber(state.trialSubscriptionCount) ?? 0; - const cancelledSubscriptionCount = - asNumber(state.cancelled_subscription_count) ?? asNumber(state.cancelledSubscriptionCount) ?? 0; - const hasActiveSubscription = - asBoolean(state.has_active_subscription) ?? - asBoolean(state.hasActiveSubscription) ?? - activeSubscriptionCount + trialSubscriptionCount > 0; - const customerId = - asString(state.customer_id) ?? - asString(state.customerId) ?? - asString(getPathValue(state, ['customer', 'id'])); - const status = asString(state.status) ?? m.billing_status_unknown(); - - return { - status, - hasActiveSubscription, - customerId, - activeSubscriptionCount, - trialSubscriptionCount, - cancelledSubscriptionCount - }; -}; - -const readApiError = (payload: unknown, fallback: string): string => { - if (isRecord(payload)) { - const message = payload.message; - if (Array.isArray(message)) { - const fromArray = message.map(asString).filter(Boolean).join(', '); - if (fromArray.length > 0) return fromArray; - } - const fromField = asString(message); - if (fromField) return fromField; - } - return fallback; -}; - -const executeApiRequest = async ( - request: () => Promise, - fallback: string -): Promise => { - try { - return { data: await request(), error: null }; - } catch (error) { - return { - data: null, - error: - error instanceof ApiServiceError - ? readApiError(error.payload, fallback) - : error instanceof Error - ? error.message - : fallback - }; - } -}; - -const findRedirectUrl = (payload: unknown): string | null => { - if (typeof payload === 'string' && /^https?:\/\//i.test(payload)) { - return payload; - } - if (Array.isArray(payload)) { - for (const item of payload) { - const fromArray = findRedirectUrl(item); - if (fromArray) return fromArray; - } - return null; - } - if (!isRecord(payload)) return null; - - const directKeys = [ - 'url', - 'checkout_url', - 'checkoutUrl', - 'portal_url', - 'portalUrl', - 'customer_portal_url', - 'customerPortalUrl' - ]; - for (const key of directKeys) { - const candidate = asString(payload[key]); - if (candidate && /^https?:\/\//i.test(candidate)) return candidate; - } - - for (const value of Object.values(payload)) { - const nested = findRedirectUrl(value); - if (nested) return nested; - } - return null; -}; - -const requireAuth = (jwt: string | null, redirectTarget: string) => { - if (!jwt) { - throw redirect( - 303, - buildLoginPath({ - redirectTo: redirectTarget, - reason: 'auth-required' - }) - ); - } -}; - -export const load: PageServerLoad = async ({ fetch, locals, url }) => { - requireAuth(locals.jwtString, `${url.pathname}${url.search}`); - const api = new ApiService({ - fetchFn: fetch, - authToken: locals.jwtString - }); - - const [productsResult, subscriptionsResult, stateResult] = await Promise.all([ - executeApiRequest(() => api.getPaymentsProducts(), m.billing_load_products_failed()), - executeApiRequest(() => api.getPaymentsSubscriptions(), m.billing_load_subscriptions_failed()), - executeApiRequest(() => api.getPaymentsSubscriptionState(), m.billing_load_state_failed()) - ]); - - const subscriptions = normalizeSubscriptions(subscriptionsResult.data); - - return { - products: normalizeProducts(productsResult.data), - subscriptions, - subscriptionState: normalizeState(stateResult.data), - errors: { - products: productsResult.error, - subscriptions: subscriptionsResult.error, - state: stateResult.error - } - }; -}; - -export const actions: Actions = { - createCheckoutSession: async ({ request, fetch, url, locals }) => { - requireAuth(locals.jwtString, `${url.pathname}${url.search}`); - const api = new ApiService({ - fetchFn: fetch, - authToken: locals.jwtString - }); - - const formData = await request.formData(); - const products = formData - .getAll('products') - .map((value) => String(value).trim()) - .filter((value) => value.length > 0); - - if (products.length === 0) { - return fail(400, { - action: 'checkout', - message: m.billing_select_product_before_checkout() - }); - } - - const customerName = asString(formData.get('customer_name')); - const customerEmail = asString(formData.get('customer_email')); - const allowDiscountCodes = asBoolean(formData.get('allow_discount_codes')) ?? true; - const allowTrial = asBoolean(formData.get('allow_trial')) ?? true; - const returnUrl = asString(formData.get('return_url')) ?? `${url.origin}/account/billing`; - const successUrl = - asString(formData.get('success_url')) ?? `${url.origin}/account/billing?checkout=success`; - - const payload: UnknownRecord = { - products, - return_url: returnUrl, - success_url: successUrl, - allow_discount_codes: allowDiscountCodes, - allow_trial: allowTrial - }; - if (customerName) payload.customer_name = customerName; - if (customerEmail) payload.customer_email = customerEmail; - - let responsePayload: unknown; - try { - responsePayload = await api.createPaymentsCheckoutSession(payload); - } catch (error) { - return fail(error instanceof ApiServiceError ? error.status : 502, { - action: 'checkout', - message: readApiError( - error instanceof ApiServiceError ? error.payload : error, - m.billing_checkout_session_failed() - ) - }); - } - - const redirectUrl = findRedirectUrl(responsePayload); - if (!redirectUrl) { - return fail(502, { - action: 'checkout', - message: m.billing_checkout_redirect_missing() - }); - } - - return { - action: 'checkout', - message: m.billing_checkout_session_created(), - redirectUrl - }; - }, - - createPortalSession: async ({ request, fetch, url, locals }) => { - requireAuth(locals.jwtString, `${url.pathname}${url.search}`); - const api = new ApiService({ - fetchFn: fetch, - authToken: locals.jwtString - }); - - const formData = await request.formData(); - const returnUrl = asString(formData.get('return_url')) ?? `${url.origin}/account/billing`; - const payload = { return_url: returnUrl }; - - let responsePayload: unknown; - try { - responsePayload = await api.createPaymentsPortalSession(payload); - } catch (error) { - return fail(error instanceof ApiServiceError ? error.status : 502, { - action: 'portal', - message: readApiError( - error instanceof ApiServiceError ? error.payload : error, - m.billing_portal_open_failed() - ) - }); - } - - const redirectUrl = findRedirectUrl(responsePayload); - if (!redirectUrl) { - return fail(502, { - action: 'portal', - message: m.billing_portal_redirect_missing() - }); - } - - return { - action: 'portal', - message: m.billing_portal_session_created(), - redirectUrl - }; - }, - - cancelSubscription: async ({ request, fetch, locals, url }) => { - requireAuth(locals.jwtString, `${url.pathname}${url.search}`); - const api = new ApiService({ - fetchFn: fetch, - authToken: locals.jwtString - }); - - const formData = await request.formData(); - const subscriptionId = asString(formData.get('subscription_id')); - - if (!subscriptionId) { - return fail(400, { - action: 'cancel', - message: m.billing_subscription_id_required() - }); - } - - try { - await api.cancelPaymentsSubscription(subscriptionId); - } catch (error) { - return fail(error instanceof ApiServiceError ? error.status : 502, { - action: 'cancel', - message: readApiError( - error instanceof ApiServiceError ? error.payload : error, - m.billing_subscription_cancel_failed() - ) - }); - } - - return { - action: 'cancel', - message: m.billing_subscription_canceled(), - cancelledId: subscriptionId - }; - } -}; diff --git a/src/routes/account/billing/+page.svelte b/src/routes/account/billing/+page.svelte deleted file mode 100644 index 800ede22..00000000 --- a/src/routes/account/billing/+page.svelte +++ /dev/null @@ -1,815 +0,0 @@ - - - - {m.nav_billing()} - {m.app_name()} - - - -
-
-
-

{m.billing_heading()}

-

{m.billing_subtitle()}

-
-
- - {#if subscriptionState.customerId} - - {subscriptionState.customerId} - - {/if} -
-
- -
- -

{formatNumber(subscriptionState.activeSubscriptionCount)}

-
- - -

{formatNumber(subscriptionState.trialSubscriptionCount)}

-
- - -

- {formatNumber(subscriptionState.cancelledSubscriptionCount)} -

-
- - -

{formatNumber(products.length)}

-
-
- - {#if hasLoadErrors} - -

{m.billing_api_warnings_subtitle()}

-
    - {#if data.errors?.products} -
  • {m.billing_error_products({ message: data.errors.products })}
  • - {/if} - {#if data.errors?.subscriptions} -
  • {m.billing_error_subscriptions({ message: data.errors.subscriptions })}
  • - {/if} - {#if data.errors?.state} -
  • {m.billing_error_state({ message: data.errors.state })}
  • - {/if} -
-
- {/if} - -
-
- -
- -
- {#if visibleProducts.length === 0} - -

{m.billing_no_products()}

-
- {:else} - {#each visibleProducts as product (product.id)} -
-
-
-

{product.name}

-

{product.description || m.billing_no_product_description()}

-
- -
- -
- - {#if !product.active} - - {/if} -
- - toggleProductSelection(product.id, checked)} - /> - - {#if isProductSelected(product.id)} - - {/if} -
- {/each} - {/if} -
- -
- (showArchivedProducts = checked)} - /> - - {m.billing_clear_selection()} - -
- - -
- (customerName = (event.target as HTMLInputElement).value)} - /> - (customerEmail = (event.target as HTMLInputElement).value)} - /> - (allowDiscountCodes = checked)} - /> - (allowTrial = checked)} - /> -
-
- - - - {#if customerName} - - {/if} - {#if customerEmail} - - {/if} - -
- {m.billing_selected_count({ - count: formatNumber(selectedProductIds.length) - })} - - - {m.billing_launch_checkout()} - - -
-
-
-
-
- - - - -
- {m.billing_open_portal()} -
-
- - - {#snippet cell( - row: BillingSubscription, - col: CwColumnDef, - defaultValue: string - )} - {#if col.key === 'status'} - - {:else if col.key === 'startedAt'} - {formatDate(row.startedAt)} - {:else if col.key === 'renewsAt'} - {formatDate(row.renewsAt)} - {:else} - {defaultValue} - {/if} - {/snippet} - - {#snippet rowActions(row: BillingSubscription)} - {#if row.status.toLowerCase().includes('cancel') || row.status - .toLowerCase() - .includes('revoke')} - - {:else} - openCancelDialog(row)}>{m.action_cancel()} - {/if} - {/snippet} - -
-
-
-
-
- - - {#if subscriptionToCancel} - -

- {m.billing_cancel_subscription_body({ - productName: subscriptionToCancel.productName, - id: subscriptionToCancel.id - })} -

-

- {m.billing_cancel_subscription_warning()} -

-
- {/if} - - {#snippet actions()} - - {m.billing_keep_subscription()} -
- - - {m.billing_confirm_cancel()} - -
-
- {/snippet} -
- - diff --git a/src/routes/locations/[location_id]/+page.server.ts b/src/routes/locations/[location_id]/+page.server.ts index 277f400e..855b5bec 100644 --- a/src/routes/locations/[location_id]/+page.server.ts +++ b/src/routes/locations/[location_id]/+page.server.ts @@ -1,4 +1,5 @@ import { ApiService } from '$lib/api/api.service'; +import { canManage } from '$lib/constants/permissions'; import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ locals, fetch, params }) => { @@ -31,7 +32,7 @@ export const load: PageServerLoad = async ({ locals, fetch, params }) => { ? currentLocation.cw_location_owners : []; const currentOwner = userId ? owners.find((o) => o.user_id === userId) : null; - const hasSettings = currentOwner != null && Number(currentOwner.permission_level) <= 2; + const hasSettings = currentOwner != null && canManage(Number(currentOwner.permission_level)); return { allLocationDevices, diff --git a/src/routes/locations/[location_id]/+page.svelte b/src/routes/locations/[location_id]/+page.svelte index 69c1fe0e..8f0bfb41 100644 --- a/src/routes/locations/[location_id]/+page.svelte +++ b/src/routes/locations/[location_id]/+page.svelte @@ -2,14 +2,19 @@ import Icon from '$lib/components/Icon.svelte'; import { AppPage } from '$lib/components/layout'; import { + attachCwDeviceRefreshVisibility, + createCwDeviceRefreshScheduler, CwButton, CwCard, CwCopy, CwDataTable, type CwColumnDef, + type CwDeviceFreshness, type CwTableQuery, type CwTableResult } from '@cropwatchdevelopment/cwui'; + import { ApiService } from '$lib/api/api.service'; + import { getAppContext } from '$lib/appContext.svelte'; import { cwCopyLabels, cwDataTableLabels } from '$lib/i18n/cwuiLabels'; import { goto } from '$app/navigation'; import { backHref } from '$lib/navigation/backTo'; @@ -33,6 +38,54 @@ const offlineThresholdMs = 11 * 60 * 1000; let loading = $state(false); + const app = getAppContext(); + + // Live freshness per device, driven by the refresh scheduler: each device is + // refetched when its upload window expires, so the status column updates + // without reloading the page. + let freshnessByDevEui = $state>({}); + let lastSeenByDevEui = $state>({}); + + $effect(() => { + const authToken = app.accessToken; + const devices = data.allLocationDevices ?? []; + if (!authToken || devices.length === 0) return; + + const api = new ApiService({ authToken }); + const scheduler = createCwDeviceRefreshScheduler({ + // The list endpoint has no per-device upload_interval; default to the + // page's historical 10 min + 1 min grace ≈ the old 11-minute threshold. + defaultIntervalMinutes: 10, + fetcher: async (devEui) => { + const latest = await api.getDashboardDeviceLatest(devEui); + if (!latest) return null; + const createdAt = typeof latest.created_at === 'string' ? latest.created_at : null; + return { lastSeenAt: createdAt, data: latest }; + }, + onData: (devEui, result) => { + if (typeof result.lastSeenAt === 'string') { + lastSeenByDevEui[devEui] = result.lastSeenAt; + } + }, + onStateChange: (devEui, state) => { + freshnessByDevEui[devEui] = state; + } + }); + + scheduler.trackAll( + devices.map((device) => ({ + id: String(device.dev_eui ?? ''), + lastSeenAt: device.created_at ?? null + })) + ); + + const detachVisibility = attachCwDeviceRefreshVisibility(scheduler); + return () => { + detachVisibility(); + scheduler.destroy(); + }; + }); + const selectedLocationId = $derived(page.params.location_id); const selectedLocationName = $derived((page.url.searchParams.get('location_name') ?? '').trim()); const locationLabel = $derived( @@ -45,17 +98,25 @@ const columns: CwColumnDef[] = [ { key: 'name', header: m.devices_device_name(), sortable: true }, + { key: 'status', header: m.devices_status(), sortable: true, width: '8rem' }, { key: 'dev_eui', header: 'DevEUI', sortable: true, width: '14rem', hideBelow: 'sm' } ]; const locationDevices = $derived.by(() => { const now = Date.now(); return (data.allLocationDevices ?? []).map((device) => { - const createdAt = device.created_at ? new Date(device.created_at) : null; - const createdAtMs = createdAt?.getTime() ?? NaN; - const isOffline = !Number.isFinite(createdAtMs) || now - createdAtMs > offlineThresholdMs; + const devEui = String(device.dev_eui ?? ''); + const lastSeen = lastSeenByDevEui[devEui] ?? device.created_at ?? null; + const lastSeenMs = lastSeen ? new Date(lastSeen).getTime() : NaN; + const freshness = freshnessByDevEui[devEui]; + // Scheduler verdict wins once available; otherwise fall back to the + // load-time threshold check. + const isOffline = + freshness === 'stale' || + (freshness === undefined && + (!Number.isFinite(lastSeenMs) || now - lastSeenMs > offlineThresholdMs)); return { - dev_eui: String(device.dev_eui ?? ''), + dev_eui: devEui, name: String(device.name ?? device.dev_eui ?? m.devices_unnamed_device()), status: isOffline ? 'Offline' : 'Online' } satisfies LocationDeviceRow; diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/+layout.server.ts b/src/routes/locations/[location_id]/devices/[dev_eui]/+layout.server.ts index dacf1bd9..95126514 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/+layout.server.ts +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/+layout.server.ts @@ -1,4 +1,5 @@ import { ApiService } from '$lib/api/api.service'; +import { PermissionLevel } from '$lib/constants/permissions'; import type { LayoutServerLoad } from './$types'; /** @@ -22,7 +23,7 @@ export const load: LayoutServerLoad = async ({ params, locals }) => { locationId: location_id ?? '', device: null, dataTable: null as string | null, - permissionLevel: 4 + permissionLevel: PermissionLevel.DISABLED }; } @@ -39,7 +40,7 @@ export const load: LayoutServerLoad = async ({ params, locals }) => { const owners = Array.isArray(location?.cw_location_owners) ? location.cw_location_owners : []; const currentOwner = userId ? owners.find((o) => o.user_id === userId) : null; - const permissionLevel = Number(currentOwner?.permission_level) || 4; + const permissionLevel = Number(currentOwner?.permission_level) || PermissionLevel.DISABLED; // The API response includes a `data_table` field (string) that identifies // which Supabase table this device stores telemetry in. Fall back to null @@ -62,7 +63,7 @@ export const load: LayoutServerLoad = async ({ params, locals }) => { locationId: location_id, device: null, dataTable: null as string | null, - permissionLevel: 4 + permissionLevel: PermissionLevel.DISABLED }; } }; 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 7077f5bf..78068e04 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte @@ -12,6 +12,9 @@ import { type RelayNumber, type RelayTargetState } from '$lib/devices/relay-types'; import { m } from '$lib/paraglide/messages.js'; import { + attachCwDeviceRefreshVisibility, + createCwDeviceRefreshScheduler, + normalizeCwUploadIntervalMinutes, CwButton, CwCard, CwDialog, @@ -21,8 +24,9 @@ import { cwResponsiveLineChartLabels } from '$lib/i18n/cwuiLabels'; import { appTheme } from '$lib/theme/appTheme.svelte'; import { resolveExportTimeZone } from './csvExport'; + import { canManage, PermissionLevel } from '$lib/constants/permissions'; import type { PageProps } from './$types'; - import { onDestroy } from 'svelte'; + import { onDestroy, untrack } from 'svelte'; import { onAppForeground } from '$lib/utils/onAppForeground'; import DeviceDashboardHeader from './DeviceDashboardHeader.svelte'; import { @@ -87,7 +91,7 @@ let locationId = $derived(data.locationId ?? ''); let authToken = $derived(data.authToken ?? null); let isRelayDevice = $derived(isRelayTable(data.dataTable)); - let permissionLevel = $derived(Number(data.permissionLevel) || 4); + let permissionLevel = $derived(Number(data.permissionLevel) || PermissionLevel.DISABLED); const serverLatestData = $derived(isTelemetryRow(data.latestData) ? data.latestData : null); let clientLatestData = $state(null); let latestData = $derived(clientLatestData ?? serverLatestData); @@ -101,15 +105,10 @@ data.device?.upload_interval ?? data.device?.cw_device_type?.default_upload_interval ); // `upload_interval` is a Postgres `numeric` column, so Supabase returns it as - // a STRING ("15", "-1", …). Naively doing `("-1" || 10) * 60_000` yields - // -60000, and `setInterval` clamps a negative delay to 0 — firing the poll in - // a tight loop that freezes the tab. Coerce to a number, reject non-positive - // / non-finite values, and never poll faster than once per minute. - let refreshIntervalMs = $derived.by(() => { - const minutes = Number(upload_interval); - const safeMinutes = Number.isFinite(minutes) && minutes > 0 ? minutes : 10; - return Math.max(60_000, safeMinutes * 60_000); - }); + // a STRING ("15", "-1", …). normalizeCwUploadIntervalMinutes rejects + // non-positive / non-finite values so the refresh scheduler falls back to its + // own default instead of scheduling a tight loop. + let uploadIntervalMinutes = $derived(normalizeCwUploadIntervalMinutes(upload_interval)); let requestedHistoricalData = $derived( routeStateByKey[routeKey]?.requestedHistoricalData ?? null @@ -179,20 +178,35 @@ destroyRelayStateManager(); }); - // Poll the device for fresh telemetry at its own upload cadence. Without this - // the page never refreshes on its own (the relay manager only covers relay - // devices). `upload_interval` is in minutes; fall back to 10 when unset. + // Refetch the device when its own upload window (last seen + + // upload_interval + grace) expires, instead of polling on a fixed interval. + // While the device stays stale the scheduler retries with capped exponential + // backoff; fresh data re-arms it from the new reading. Pauses while the tab + // is hidden and reconciles on return. $effect(() => { if (!authToken || !devEui) return; - // Defence-in-depth: never schedule a non-positive interval (see the - // `refreshIntervalMs` note above) — `setInterval` would clamp it to 0. - if (!Number.isFinite(refreshIntervalMs) || refreshIntervalMs <= 0) return; + const intervalMinutes = uploadIntervalMinutes; + + const scheduler = createCwDeviceRefreshScheduler({ + fetcher: async () => { + const row = await refreshDisplayedData(); + return { lastSeenAt: row ? readCreatedAt(row) : null, data: row }; + } + }); - const timer = setInterval(() => { - void refreshDisplayedData(); - }, refreshIntervalMs); + untrack(() => { + scheduler.track({ + id: devEui, + lastSeenAt: lastUpdatedAt ?? null, + uploadIntervalMinutes: intervalMinutes + }); + }); - return () => clearInterval(timer); + const detachVisibility = attachCwDeviceRefreshVisibility(scheduler); + return () => { + detachVisibility(); + scheduler.destroy(); + }; }); function syncRelayStateBaseData(): void { @@ -374,7 +388,7 @@ !isRelayDevice || !authToken || !devEui || - permissionLevel > 2 || + !canManage(permissionLevel) || hasPendingRelayVerification ) { return; diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/DeviceDashboardHeader.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/DeviceDashboardHeader.svelte index 8e744bc2..6f0c9675 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/DeviceDashboardHeader.svelte +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/DeviceDashboardHeader.svelte @@ -6,6 +6,7 @@ import Icon from '$lib/components/Icon.svelte'; import SETTINGS_ICON from '$lib/images/icons/settings.svg'; import { m } from '$lib/paraglide/messages.js'; + import { canManage, isAdmin } from '$lib/constants/permissions'; import { CwButton, CwCard, CwDuration, CwSpinner } from '@cropwatchdevelopment/cwui'; import CsvExportDialog from './dialogs/csvExportDialog.svelte'; import CsvTrafficExportDialog from './dialogs/csvTrafficExportDialog.svelte'; @@ -91,7 +92,7 @@
- {#if permissionLevel <= 2} + {#if canManage(permissionLevel)} import { enhance } from '$app/forms'; - import { getAppContext } from '$lib/appContext.svelte'; import { getPermissionLevelOptions } from '$lib/i18n/options'; import { m } from '$lib/paraglide/messages.js'; import { CwButton, CwCard, CwDropdown, CwSeparator } from '@cropwatchdevelopment/cwui'; const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - const VALID_PERMISSION_LEVELS = new Set([1, 2, 3, 4]); + const VALID_PERMISSION_LEVELS = new Set([1, 2, 3, 4, 5]); type DeviceOwnerRow = { id: number; @@ -42,7 +41,6 @@ let { form, owners }: Props = $props(); - const app = getAppContext(); const permissionOptions = getPermissionLevelOptions(); let ownerSubmittingByKey = $state>({}); @@ -98,86 +96,84 @@ {#each permissionRows as row, index (row.key)} {@const ownerForm = ownerFormFor(row.key)} {#if row.email} - {#if !row.email.includes('@cropwatch.io') || app.session?.email.includes('@cropwatch.io')} -
-
{ - if (!isOwnerRowValid(row)) { - cancel(); - return; +
+ { + 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); } - - 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 ?? '')) } - }; - }} - > - - - - -
-

{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()} - + error={ownerForm?.fieldErrors?.permissionLevel || + (!isOwnerRowValid(row) + ? m.devices_choose_valid_permission_level() + : undefined)} + /> + {#if ownerForm?.fieldErrors?.permissionLevel} +

{ownerForm.fieldErrors.permissionLevel}

+ {/if}
- {#if ownerForm?.fieldErrors?.targetUserEmail} -

- {ownerForm.fieldErrors.targetUserEmail} -

- {/if} - - {#if ownerForm?.message} - - {/if} - -
- {#if index < permissionRows.length - 1} - - {/if} + + {m.devices_update_permission()} + +
+ + {#if ownerForm?.fieldErrors?.targetUserEmail} +

+ {ownerForm.fieldErrors.targetUserEmail} +

+ {/if} + + {#if ownerForm?.message} + + {/if} + +
+ {#if index < permissionRows.length - 1} + {/if} {/if} {/each} 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 index 4810097a..881aeff8 100644 --- 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 @@ -6,11 +6,11 @@ import { TTI_DEVICE_ID_MAX_LENGTH } from '$lib/devices/tti-device-id'; import { m } from '$lib/paraglide/messages.js'; +import { isValidPermissionLevel, PermissionLevel } from '$lib/constants/permissions'; 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; @@ -110,7 +110,10 @@ export function normalizeDeviceOwners( 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; + const permissionLevel = + typeof rawPerm === 'number' && Number.isFinite(rawPerm) + ? rawPerm + : PermissionLevel.DISABLED; return { id, @@ -227,7 +230,7 @@ export function validateDeviceOwnerPermissionValues(values: DeviceOwnerPermissio fieldErrors.targetUserEmail = m.devices_owner_email_invalid(); } - if (!Number.isFinite(permissionLevel) || !VALID_PERMISSION_LEVELS.has(permissionLevel)) { + if (!Number.isFinite(permissionLevel) || !isValidPermissionLevel(permissionLevel)) { fieldErrors.permissionLevel = m.devices_choose_valid_permission_level(); } diff --git a/src/routes/locations/[location_id]/settings/+page.server.ts b/src/routes/locations/[location_id]/settings/+page.server.ts index def6d264..4f2ac642 100644 --- a/src/routes/locations/[location_id]/settings/+page.server.ts +++ b/src/routes/locations/[location_id]/settings/+page.server.ts @@ -1,4 +1,5 @@ import { ApiService, type LocationOwnerDto } from '$lib/api/api.service'; +import { PermissionLevel } from '$lib/constants/permissions'; import { m } from '$lib/paraglide/messages.js'; import { fail } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; @@ -37,7 +38,7 @@ export const load: PageServerLoad = async ({ locals, fetch, params }) => { const owner = location?.cw_location_owners?.find( (owner) => readUnknownString(owner.user_id) === currentUserId && - readUnknownString(owner.permission_level) === '1' + readUnknownString(owner.permission_level) === String(PermissionLevel.ADMIN) ); if (!owner) { throw fail(403, { diff --git a/src/routes/locations/[location_id]/settings/LocationEditPermissions.svelte b/src/routes/locations/[location_id]/settings/LocationEditPermissions.svelte index 7bbca976..d010d683 100644 --- a/src/routes/locations/[location_id]/settings/LocationEditPermissions.svelte +++ b/src/routes/locations/[location_id]/settings/LocationEditPermissions.svelte @@ -10,7 +10,6 @@ type CwTableQuery } from '@cropwatchdevelopment/cwui'; import { cwDataTableLabels } from '$lib/i18n/cwuiLabels'; - import { getAppContext } from '$lib/appContext.svelte'; import type { LocationOwnerDto } from '$lib/api/api.dtos'; import { getPermissionLevelLabel, getPermissionLevelOptions } from '$lib/i18n/options'; import { m } from '$lib/paraglide/messages.js'; @@ -23,15 +22,9 @@ } from './location-permission-rows'; let { data }: { data: { locationOwners: LocationOwnerDto[] } } = $props(); - const app = getAppContext(); - // Hide CropWatch staff (@cropwatch.io) permission rows from customers so they are - // unaware we have access — but keep them visible when a CropWatch user is logged in. - const viewerIsCropwatch = $derived(app.session?.email?.includes('@cropwatch.io') ?? false); - let permissions = $derived( - mapLocationOwnersToPermissionRows(data.locationOwners).filter( - (permission) => viewerIsCropwatch || !permission.email.includes('@cropwatch.io') - ) - ); + // CropWatch staff rows are filtered out server-side (the API omits + // @cropwatch.io owners for non-staff requesters), so no client filter here. + let permissions = $derived(mapLocationOwnersToPermissionRows(data.locationOwners)); let openDeletePermissionDialog = $state(false); let selectedRow = $state(null); let location_id = $state(page.params.location_id); @@ -89,12 +82,12 @@ {#if col.key === 'permission_level'} {#if editingPermissionId === row.id} {:else} - {getPermissionLevelLabel(row.permission_level, m.permission_level_viewer())} + {getPermissionLevelLabel(row.permission_level)} {/if} {:else} {defaultValue} 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 index 0efc7e01..9ede35b6 100644 --- a/src/routes/locations/[location_id]/settings/location-settings-actions.server.ts +++ b/src/routes/locations/[location_id]/settings/location-settings-actions.server.ts @@ -1,5 +1,6 @@ import { ApiService, ApiServiceError, type UpdateLocationOwnerRequest } from '$lib/api/api.service'; import { m } from '$lib/paraglide/messages.js'; +import { PermissionLevel } from '$lib/constants/permissions'; import { fail } from '@sveltejs/kit'; import type { Actions } from './$types'; @@ -22,7 +23,7 @@ type EditPermissionFormValues = { const EMPTY_ADD_VALUES: AddPermissionFormValues = { newUserEmail: '', userId: '', - permission_level: 4, + permission_level: PermissionLevel.DISABLED, applyToAllDevices: false }; @@ -149,7 +150,8 @@ export const addPermission: Actions['addPermission'] = async ({ const formData = await request.formData(); const values: AddPermissionFormValues = { newUserEmail: readString(formData.get('newUserEmail')), - permission_level: Number.parseInt(readString(formData.get('permission_level')), 10) || 4, + permission_level: Number.parseInt(readString(formData.get('permission_level')), 10) || + PermissionLevel.DISABLED, applyToAllDevices: formData.get('applyToAllDevices') === 'true' }; @@ -178,7 +180,7 @@ export const addPermission: Actions['addPermission'] = async ({ await apiService.createLocationPermission( locationId, values.newUserEmail, - values.permission_level ?? 4, + values.permission_level ?? PermissionLevel.DISABLED, values.applyToAllDevices ); diff --git a/src/routes/reports-new/+page.svelte b/src/routes/reports-new/+page.svelte deleted file mode 100644 index d13f34de..00000000 --- a/src/routes/reports-new/+page.svelte +++ /dev/null @@ -1,237 +0,0 @@ - - - - {m.reports_new_page_title()} - - - - goto(backHref(page.url, resolve('/')))}> - ← {m.action_back_to_dashboard()} - - - - {#key tableKey} - - {#snippet cell( - row: ReportTemplateRow, - col: CwColumnDef, - defaultValue: string - )} - {#if col.key === 'statusLabel'} - - {:else} - {defaultValue} - {/if} - {/snippet} - - {#snippet rowActions(row: ReportTemplateRow)} -
- goto(resolve('/reports-new/edit/[id]', { id: String(row.id) }))} - > - - - - -
- {/snippet} - - {#snippet toolbarActions()} - goto(resolve('/reports-new/create'))}> - - - {/snippet} -
- {/key} -
-
- - diff --git a/src/routes/reports-new/[...path]/+page.server.ts b/src/routes/reports-new/[...path]/+page.server.ts new file mode 100644 index 00000000..afcacad5 --- /dev/null +++ b/src/routes/reports-new/[...path]/+page.server.ts @@ -0,0 +1,8 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +// The template-based reports pages took over /reports; keep old bookmarks working. +export const load: PageServerLoad = ({ params }) => { + const suffix = params.path ? `/${params.path}` : ''; + redirect(301, `/reports${suffix}`); +}; diff --git a/src/routes/reports/+page.server.ts b/src/routes/reports/+page.server.ts deleted file mode 100644 index 6b7ae945..00000000 --- a/src/routes/reports/+page.server.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { PageServerLoad } from './$types'; - -// The reports list page uses CwDataTable with a client-side loadData callback that -// fetches, transforms, and paginates reports on demand. Pre-fetching here would -// duplicate that work and be thrown away immediately. The root layout supplies -// `authToken` and `session` to every route via layout data inheritance. -export const load: PageServerLoad = async () => { - return {}; -}; diff --git a/src/routes/reports/+page.svelte b/src/routes/reports/+page.svelte index ff86355f..ef724d8b 100644 --- a/src/routes/reports/+page.svelte +++ b/src/routes/reports/+page.svelte @@ -3,11 +3,16 @@ import { page } from '$app/state'; import { backHref } from '$lib/navigation/backTo'; import { resolve } from '$app/paths'; + import { readApiErrorMessage } from '$lib/api/api-error'; + import { ApiService } from '$lib/api/api.service'; + import { getAppContext } from '$lib/appContext.svelte'; import Icon from '$lib/components/Icon.svelte'; import { AppPage } from '$lib/components/layout'; + import type { ReportTemplateDto } from '$lib/api/api.dtos'; import { CwButton, CwCard, + CwChip, CwDataTable, type CwColumnDef, type CwTableQuery, @@ -15,89 +20,151 @@ } from '@cropwatchdevelopment/cwui'; import { cwDataTableLabels } from '$lib/i18n/cwuiLabels'; import { m } from '$lib/paraglide/messages.js'; - import ReportHistoryDialog from './ReportHistoryDialog.svelte'; - import DeleteReportDialog from './DeleteReportDialog.svelte'; - import type { ReportDeviceRelations, ReportRow } from './report-row'; import ADD_ICON from '$lib/images/icons/add.svg'; import EDIT_ICON from '$lib/images/icons/edit.svg'; - import { getAppContext } from '$lib/appContext.svelte'; - import { ApiService } from '$lib/api/api.service'; - import { formatDateTime } from '$lib/i18n/format'; - - type ReportApiRow = ReportDeviceRelations & { created_at: string }; + import DeleteReportTemplateDialog from './DeleteReportTemplateDialog.svelte'; + import ViewReportHistoryDialog from './ViewReportHistoryDialog.svelte'; + + type ReportTemplateRow = ReportTemplateDto & { + statusLabel: string; + locationName: string; + assignmentSummary: string; + recipientSummary: string; + cadenceSummary: string; + createdAtLabel: string; + }; + + const columns: CwColumnDef[] = [ + { key: 'name', header: m.common_name(), sortable: true }, + { key: 'statusLabel', header: m.reports_new_status(), sortable: true }, + { key: 'assignmentSummary', header: m.reports_new_assigned_devices() }, + { key: 'locationName', header: m.nav_locations(), sortable: true }, + { key: 'recipientSummary', header: m.reports_new_recipients() }, + { key: 'cadenceSummary', header: m.reports_new_cadence() }, + { key: 'createdAtLabel', header: m.common_created(), sortable: true } + ]; let loading = $state(true); - let deletedReportIds = $state([]); + let deletedTemplateIds = $state([]); + let tableKey = $derived(deletedTemplateIds.join(',')); let app = getAppContext(); - const columns: CwColumnDef[] = [ - { key: 'name', header: m.common_name(), sortable: true }, - { key: 'device_name', header: m.reports_for_device(), sortable: true }, - { key: 'dev_eui', header: m.devices_dev_eui_label() }, - { key: 'location_name', header: m.nav_locations(), sortable: true } - ]; + async function loadData(query: CwTableQuery): Promise> { + try { + const api = new ApiService({ authToken: app.accessToken }); + const templates = await api.getReportTemplates( + { search: query.search?.trim() || undefined }, + { signal: query.signal } + ); + const deletedIds = new Set(deletedTemplateIds); + let rows = templates.filter((template) => !deletedIds.has(template.id)).map(toRow); + + if (query.sort) { + rows = sortRows(rows, query.sort.column, query.sort.direction); + } + + const total = rows.length; + const skip = (query.page - 1) * query.pageSize; + rows = rows.slice(skip, skip + query.pageSize); + + return { rows, total }; + } catch (error) { + throw new Error(readApiErrorMessage(error, m.reports_new_load_failed())); + } finally { + loading = false; + } + } - const tableKey = $derived(deletedReportIds.join(',')); + function handleDeleted(templateId: number) { + if (deletedTemplateIds.includes(templateId)) return; + deletedTemplateIds = [...deletedTemplateIds, templateId]; + } - function handleReportDeleted(reportId: string) { - if (deletedReportIds.includes(reportId)) return; - deletedReportIds = [...deletedReportIds, reportId]; + function toRow(template: ReportTemplateDto): ReportTemplateRow { + return { + ...template, + statusLabel: template.isActive ? m.reports_new_active() : m.reports_new_inactive(), + locationName: summarizeLocations(template), + assignmentSummary: summarizeAssignments(template), + recipientSummary: summarizeRecipients(template), + cadenceSummary: summarizeCadence(template), + createdAtLabel: template.createdAt + ? new Date(template.createdAt).toLocaleString() + : m.common_not_available() + }; } - function sortReports(rows: ReportRow[], column: string, direction: 'asc' | 'desc'): ReportRow[] { - const dir = direction === 'asc' ? 1 : -1; - return [...rows].sort((a, b) => { - const aVal = (a as unknown as Record)[column]; - const bVal = (b as unknown as Record)[column]; - if (aVal == null && bVal == null) return 0; - if (aVal == null) return dir; - if (bVal == null) return -dir; - return String(aVal).localeCompare(String(bVal)) * dir; - }); + function summarizeLocations(template: ReportTemplateDto): string { + const names = template.assignments + .map((assignment) => assignment.locationName) + .filter((name): name is string => !!name && name.trim().length > 0); + const unique = [...new Set(names)]; + if (unique.length === 0) return m.reports_unknown_location(); + return unique.length === 1 ? unique[0] : unique.join(', '); } - async function loadData(query: CwTableQuery): Promise> { - const search = query.search?.trim() || ''; - const api = new ApiService({ authToken: app.accessToken }); - const reports = await api.getReports({ name: search || undefined }); - - const deletedIds = new Set(deletedReportIds); - let rows: ReportRow[] = reports - .map((report) => { - const r = report as typeof report & ReportApiRow; - const deviceOwners = r.cw_devices?.cw_device_owners ?? []; - return { - ...r, - permission_level: - deviceOwners.find( - (o) => - o.user_id === app.session?.sub && - o.permission_level != null && - o.permission_level <= 3 - )?.permission_level ?? null, - created_at: formatDateTime(r.created_at), - location_name: - r.cw_devices?.cw_locations?.name ?? m.reports_unknown_location(), - device_name: r.cw_devices?.name ?? m.reports_unknown_device() - }; - }) - .filter((r) => !deletedIds.has(r.report_id)); - - if (query.sort) { - rows = sortReports(rows, query.sort.column, query.sort.direction); + function summarizeAssignments(template: ReportTemplateDto): string { + if (template.assignments.length === 0) return m.reports_new_no_assignments(); + + const labels = template.assignments.map((assignment) => + assignment.deviceName ? `${assignment.deviceName} (${assignment.devEui})` : assignment.devEui + ); + + return summarizeList(labels); + } + + function summarizeRecipients(template: ReportTemplateDto): string { + if (template.recipients.length === 0) return m.common_not_available(); + + return summarizeList( + template.recipients.map( + (recipient) => recipient.email ?? recipient.name ?? String(recipient.communicationMethod) + ) + ); + } + + function summarizeCadence(template: ReportTemplateDto): string { + const flags: string[] = []; + for (const schedule of template.schedule) { + if (!schedule.isActive) continue; + if (schedule.endOfDay) flags.push(m.reports_new_cadence_daily()); + if (schedule.endOfWeek) flags.push(m.reports_new_cadence_weekly()); + if (schedule.endOfMonth) flags.push(m.reports_new_cadence_monthly()); } + const unique = [...new Set(flags)]; + return unique.length > 0 ? unique.join(', ') : m.common_not_available(); + } - const total = rows.length; - const skip = (query.page - 1) * query.pageSize; - rows = rows.slice(skip, skip + query.pageSize); + function summarizeList(items: string[]): string { + const visible = items.slice(0, 2).join(', '); + const remaining = items.length - 2; + return remaining > 0 + ? m.reports_new_summary_more({ summary: visible, count: String(remaining) }) + : visible; + } + + function sortRows( + rows: ReportTemplateRow[], + column: string, + direction: 'asc' | 'desc' + ): ReportTemplateRow[] { + const dir = direction === 'asc' ? 1 : -1; - loading = false; - return { rows, total }; + return [...rows].sort((a, b) => { + const left = (a as unknown as Record)[column]; + const right = (b as unknown as Record)[column]; + + if (typeof left === 'number' && typeof right === 'number') { + return (left - right) * dir; + } + + return String(left ?? '').localeCompare(String(right ?? '')) * dir; + }); } - {m.reports_page_title()} + {m.reports_new_page_title()} @@ -105,69 +172,55 @@ ← {m.action_back_to_dashboard()} - + {#key tableKey} - - {#snippet cell(row: ReportRow, col: CwColumnDef, defaultValue: string)} - {#if col.key === 'device_name'} - {#if row.cw_devices?.cw_locations?.location_id} - - {row.cw_devices?.name ?? m.reports_unknown_device()} - - {:else} - {row.cw_devices?.name ?? m.reports_unknown_device()} - {/if} + {#snippet cell( + row: ReportTemplateRow, + col: CwColumnDef, + defaultValue: string + )} + {#if col.key === 'statusLabel'} + {:else} {defaultValue} {/if} {/snippet} - {#snippet rowActions(row: ReportRow)} -
- {#if row.permission_level != null && row.permission_level <= 3} - - {/if} - {#if row.permission_level != null && row.permission_level <= 2} - { - goto(resolve('/reports/[report_id]/edit', { report_id: row.report_id })); - }} - > - - - {/if} - {#if row.user_id === app.session?.sub} - - {/if} + {#snippet rowActions(row: ReportTemplateRow)} +
+ goto(resolve('/reports/edit/[id]', { id: String(row.id) }))} + > + + + +
{/snippet} {#snippet toolbarActions()} - { - goto(resolve('/reports/new/edit')); - }} - > - + goto(resolve('/reports/create'))}> + {/snippet} @@ -176,7 +229,9 @@ diff --git a/src/routes/reports/DeleteReportDialog.svelte b/src/routes/reports/DeleteReportDialog.svelte deleted file mode 100644 index 65c89aa1..00000000 --- a/src/routes/reports/DeleteReportDialog.svelte +++ /dev/null @@ -1,133 +0,0 @@ - - - (open = true)}> - - - - -

{m.reports_confirm_delete_body({ name: reportName })}

-
- (open = false)}> - {m.action_cancel()} - - void deleteReport(reportId)}>{m.action_delete()} -
-
diff --git a/src/routes/reports-new/DeleteReportTemplateDialog.svelte b/src/routes/reports/DeleteReportTemplateDialog.svelte similarity index 100% rename from src/routes/reports-new/DeleteReportTemplateDialog.svelte rename to src/routes/reports/DeleteReportTemplateDialog.svelte diff --git a/src/routes/reports-new/ReportCadenceSection.svelte b/src/routes/reports/ReportCadenceSection.svelte similarity index 100% rename from src/routes/reports-new/ReportCadenceSection.svelte rename to src/routes/reports/ReportCadenceSection.svelte diff --git a/src/routes/reports/ReportHistoryDialog.svelte b/src/routes/reports/ReportHistoryDialog.svelte deleted file mode 100644 index 66ac8ff5..00000000 --- a/src/routes/reports/ReportHistoryDialog.svelte +++ /dev/null @@ -1,165 +0,0 @@ - - - (open = true)}> - - - - (open = false)} class="w-full"> - - - {m.reports_problems_only_report()} - - - - {#if open} - - {#snippet rowActions(row: ReportHistoryRow)} - { - handleDownload(dev_eui, report_id, row.name); - }} - > - {m.action_download()} - - {/snippet} - - {/if} - - {#snippet actions()} - (open = false)}>{m.action_close()} - {/snippet} - diff --git a/src/routes/reports-new/ReportProcessingSchedulesSection.svelte b/src/routes/reports/ReportProcessingSchedulesSection.svelte similarity index 100% rename from src/routes/reports-new/ReportProcessingSchedulesSection.svelte rename to src/routes/reports/ReportProcessingSchedulesSection.svelte diff --git a/src/routes/reports-new/ReportRecipientsSection.svelte b/src/routes/reports/ReportRecipientsSection.svelte similarity index 100% rename from src/routes/reports-new/ReportRecipientsSection.svelte rename to src/routes/reports/ReportRecipientsSection.svelte diff --git a/src/routes/reports-new/ReportTemplateForm.svelte b/src/routes/reports/ReportTemplateForm.svelte similarity index 98% rename from src/routes/reports-new/ReportTemplateForm.svelte rename to src/routes/reports/ReportTemplateForm.svelte index c51100de..c5342991 100644 --- a/src/routes/reports-new/ReportTemplateForm.svelte +++ b/src/routes/reports/ReportTemplateForm.svelte @@ -156,8 +156,7 @@ if (usable.length === 0) { return [ { label: m.reports_create_method_email(), value: '1' }, - { label: m.reports_create_method_sms(), value: '2' }, - { label: m.reports_create_method_discord(), value: '3' } + { label: m.reports_create_method_sms(), value: '2' } ]; } return usable.map((method) => ({ @@ -250,7 +249,7 @@ }); } - await goto(resolve('/reports-new')); + await goto(resolve('/reports')); } catch (error) { toast.add({ tone: 'danger', @@ -361,7 +360,7 @@ - goto(resolve('/reports-new'))} disabled={submitting}> + goto(resolve('/reports'))} disabled={submitting}> {m.action_cancel()} { - const authToken = locals.jwtString ?? null; - const reportId = readString(params.report_id); - const isCreate = reportId === 'new'; - const currentUser = readCurrentUser(locals); - - if (!authToken) { - error(401, m.error_unauthorized_title()); - } - - const api = new ApiService({ fetchFn: fetch, authToken }); - - if (isCreate) { - const devEui = url.searchParams.get('dev_eui')?.trim() || null; - const devices = await api.getAllDevices().catch(() => []); - return { authToken, currentUser, devices, devEui, report: null }; - } - - if (!reportId) { - error(404, m.error_not_found_title()); - } - - try { - const [report, devices] = await Promise.all([ - api.getReport(reportId), - api.getAllDevices().catch(() => []) - ]); - - return { - authToken, - currentUser, - devices, - devEui: null, - report - }; - } catch (sourceError) { - const status = readApiStatus(sourceError); - const fallback = status === 404 ? m.error_not_found_title() : REPORT_UPDATE_FAILED_MESSAGE; - error(status, readApiMessage(sourceError, fallback)); - } -}; - -export const actions: Actions = { - default: async ({ request, locals, fetch, params }) => { - const authToken = locals.jwtString ?? null; - const reportId = readString(params.report_id); - const isCreate = reportId === 'new'; - const currentUser = readCurrentUser(locals); - - if (!authToken) { - return fail(401, { error: m.auth_not_authenticated() }); - } - - if (!isCreate && !reportId) { - return fail(404, { error: m.error_not_found_title() }); - } - - const parsedPayload = parsePayload(await request.formData()); - if (!parsedPayload) { - return fail(400, { - error: m.reports_create_payload_unreadable() - }); - } - - const sanitizedPayload = sanitizeReportPayload(parsedPayload.value, { - report_id: isCreate ? undefined : reportId, - user_id: currentUser?.id - }); - - if (!sanitizedPayload.name) { - return fail(400, { - error: m.validation_name_required(), - payload: parsedPayload.raw - }); - } - - if (!sanitizedPayload.dev_eui) { - return fail(400, { - error: m.devices_dev_eui_required(), - payload: parsedPayload.raw - }); - } - - if (sanitizedPayload.dev_eui.length !== 16) { - return fail(400, { - error: m.devices_dev_eui_invalid(), - payload: parsedPayload.raw - }); - } - - const api = new ApiService({ - fetchFn: fetch, - authToken - }); - - try { - if (isCreate) { - const createdReport = await api.createReport(sanitizedPayload); - - return { - success: true, - message: REPORT_CREATED_SUCCESS_MESSAGE, - report_id: readReportId(createdReport) ?? undefined - }; - } - - await api.updateReport(reportId, sanitizedPayload); - - return { - success: true, - message: REPORT_UPDATED_SUCCESS_MESSAGE, - report_id: reportId - }; - } catch (sourceError) { - const message = readApiMessage( - sourceError, - isCreate ? m.reports_create_failed() : REPORT_UPDATE_FAILED_MESSAGE - ); - const status = readApiStatus(sourceError); - - return fail(status, { - error: message, - payload: parsedPayload.raw - }); - } - } -}; diff --git a/src/routes/reports/[report_id]/edit/+page.svelte b/src/routes/reports/[report_id]/edit/+page.svelte deleted file mode 100644 index 681566c1..00000000 --- a/src/routes/reports/[report_id]/edit/+page.svelte +++ /dev/null @@ -1,400 +0,0 @@ - - - - {isEditing ? m.reports_edit_page_title() : m.reports_create_page_title()} | CropWatch - - - -
- goto(resolve('/reports'))}> - ← {m.action_back()} - - -
{ - submitAttempted = true; - - if (!canSubmit) { - cancel(); - toast.add({ - tone: 'danger', - message: validationIssues[0] ?? m.reports_create_invalid_toast() - }); - return; - } - - submitting = true; - - return async ({ result }) => { - submitting = false; - - if (result.type === 'success') { - toast.add({ - tone: 'success', - message: - typeof result.data?.message === 'string' - ? result.data.message - : isEditing - ? REPORT_UPDATED_SUCCESS_MESSAGE - : REPORT_CREATED_SUCCESS_MESSAGE - }); - await goto(resolve('/reports'), { invalidateAll: true }); - return; - } - - await applyAction(result); - - if (result.type === 'failure' && typeof result.data?.error === 'string') { - toast.add({ tone: 'danger', message: result.data.error }); - return; - } - - if (result.type === 'error') { - toast.add({ - tone: 'danger', - message: isEditing ? REPORT_UPDATED_SUCCESS_MESSAGE : REPORT_CREATED_SUCCESS_MESSAGE - }); - } - }; - }} - > - - - {#if actionForm?.error} - -

{actionForm.error}

-
- {/if} - - {#if submitAttempted && validationIssues.length > 0} - -
-

{m.reports_create_validation_copy()}

-
- -
    - {#each validationIssues as issue (issue)} -
  • {issue}
  • - {/each} -
-
- {/if} - - - -
- - - -
- -
- -
- - {#if !loadingDevices && deviceOptions.length === 0} - -

{m.reports_create_no_devices_loaded()}

-
- {/if} -
-
- - - - - - - -

{m.reports_create_alerts_copy()}

- -
-
- - - - - - - goto(resolve('/reports'))}> - - {m.action_cancel()} - - - - {#if submitting} - {m.action_saving()} - {:else if isEditing} - {m.action_save_changes()} - {:else} - {m.reports_create_submit()} - {/if} - - - - - -
-
- - diff --git a/src/routes/reports/[report_id]/edit/ReportCadenceSection.svelte b/src/routes/reports/[report_id]/edit/ReportCadenceSection.svelte deleted file mode 100644 index 511ed2d2..00000000 --- a/src/routes/reports/[report_id]/edit/ReportCadenceSection.svelte +++ /dev/null @@ -1,84 +0,0 @@ - - - - - {#if schedules.length === 0} - -

{m.reports_create_empty_schedules()}

-
- {/if} - - {#each schedules as schedule, index (schedule.key)} -
-
-
-

{m.reports_create_schedule_heading({ index: String(index + 1) })}

-

{m.reports_create_schedule_copy()}

-
-
- -
- (schedule.end_of_week = checked)} - /> - (schedule.end_of_day = checked)} - /> -
-
- {/each} -
-
- - diff --git a/src/routes/reports/[report_id]/edit/ReportProcessingSchedulesSection.svelte b/src/routes/reports/[report_id]/edit/ReportProcessingSchedulesSection.svelte deleted file mode 100644 index ac7b5b45..00000000 --- a/src/routes/reports/[report_id]/edit/ReportProcessingSchedulesSection.svelte +++ /dev/null @@ -1,201 +0,0 @@ - - - - -
-

{m.reports_schedule_card_copy()}

- - - -
- - {#if schedules.length === 0} - -

{m.reports_schedule_empty()}

-
- {/if} - - {#each schedules as schedule, index (schedule.key)} -
-
-
-

{m.reports_schedule_entry_heading({ index: String(index + 1) })}

-
- onRemove(schedule.key)}> - {m.action_remove()} - -
- -
- - - -
- -
- - -
- -
- (schedule.crosses_midnight = checked)} - /> - (schedule.is_enabled = checked)} - /> -
-
- {/each} -
-
- - diff --git a/src/routes/reports/[report_id]/edit/ReportRecipientsSection.svelte b/src/routes/reports/[report_id]/edit/ReportRecipientsSection.svelte deleted file mode 100644 index 00df7d31..00000000 --- a/src/routes/reports/[report_id]/edit/ReportRecipientsSection.svelte +++ /dev/null @@ -1,137 +0,0 @@ - - - - -
-

{m.reports_create_recipients_copy()}

- - - -
- - {#if recipients.length === 0} - -

{m.reports_create_empty_recipients()}

-
- {/if} - - {#each recipients as recipient, index (recipient.key)} -
-
-
-

{m.reports_create_recipient_heading({ index: String(index + 1) })}

-

{m.reports_create_recipient_copy()}

-
- {#if recipients.length > 1} - onRemove(recipient.key)} - > - {m.action_remove()} - - {/if} -
- -
- - - -
-
- {/each} -
-
- - diff --git a/src/routes/reports/[report_id]/edit/alert-points-editor-text.ts b/src/routes/reports/[report_id]/edit/alert-points-editor-text.ts deleted file mode 100644 index 3fe601bd..00000000 --- a/src/routes/reports/[report_id]/edit/alert-points-editor-text.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { getUiLocale } from '$lib/i18n/format'; -import { m } from '$lib/paraglide/messages.js'; -import type { CwAlertPointsEditorText } from '@cropwatchdevelopment/cwui'; - -export function createReportAlertPointsEditorText(): CwAlertPointsEditorText { - return { - unitFieldLabel: m.reports_create_alert_points_unit_label(), - centerFieldLabel: m.reports_create_alert_points_center_label(), - nameFieldLabel: m.common_name(), - conditionFieldLabel: m.reports_create_alert_operator(), - valueFieldLabel: m.reports_create_alert_value(), - minValueFieldLabel: m.reports_create_alert_minimum(), - maxValueFieldLabel: m.reports_create_alert_maximum(), - colorFieldLabel: m.reports_create_alert_hex_color(), - addAlertPointButton: m.reports_create_add_alert(), - 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) - }), - minEqualsMaxWarning: m.reports_create_alert_points_equal_bounds_warning(), - defaultPointName: (index) => m.reports_create_alert_heading({ index: 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 }), - overlapError: (labels) => - m.reports_create_alert_points_overlap_error({ - labels: labels.join(getUiLocale() === 'ja' ? '、' : ', ') - }) - }; -} diff --git a/src/routes/reports/[report_id]/edit/data-pull-interval.ts b/src/routes/reports/[report_id]/edit/data-pull-interval.ts deleted file mode 100644 index e5eaad1f..00000000 --- a/src/routes/reports/[report_id]/edit/data-pull-interval.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { SelectOption } from './report-form.types'; - -export const DEFAULT_REPORT_DATA_PULL_INTERVAL = '30'; - -const BASE_REPORT_DATA_PULL_INTERVAL_OPTIONS: ReadonlyArray = Object.freeze([ - { label: '30 minutes', value: '30' }, - { label: '1 hour', value: '60' } -]); - -function readPositiveInteger(value: unknown): number | undefined { - if (typeof value === 'number' && Number.isInteger(value) && value > 0) { - return value; - } - - if (typeof value !== 'string') { - return undefined; - } - - const normalized = value.trim(); - if (!normalized) { - return undefined; - } - - const parsed = Number.parseInt(normalized, 10); - return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; -} - -function formatIntervalLabel(minutes: number): string { - if (minutes % 60 === 0) { - const hours = minutes / 60; - return hours === 1 ? '1 hour' : `${hours} hours`; - } - - return `${minutes} minutes`; -} - -export function parseReportDataPullInterval(value: unknown): number | undefined { - return readPositiveInteger(value); -} - -export function normalizeReportDataPullInterval( - value: unknown, - fallback = DEFAULT_REPORT_DATA_PULL_INTERVAL -): string { - return String(readPositiveInteger(value) ?? fallback); -} - -export function buildReportDataPullIntervalOptions( - currentValue: unknown -): ReadonlyArray { - const normalizedValue = readPositiveInteger(currentValue); - - if ( - normalizedValue === undefined || - BASE_REPORT_DATA_PULL_INTERVAL_OPTIONS.some( - (option) => option.value === String(normalizedValue) - ) - ) { - return BASE_REPORT_DATA_PULL_INTERVAL_OPTIONS; - } - - return [ - { - label: formatIntervalLabel(normalizedValue), - value: String(normalizedValue) - }, - ...BASE_REPORT_DATA_PULL_INTERVAL_OPTIONS - ]; -} diff --git a/src/routes/reports/[report_id]/edit/report-action.server.ts b/src/routes/reports/[report_id]/edit/report-action.server.ts deleted file mode 100644 index c7d40628..00000000 --- a/src/routes/reports/[report_id]/edit/report-action.server.ts +++ /dev/null @@ -1,421 +0,0 @@ -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 deleted file mode 100644 index db19be4b..00000000 --- a/src/routes/reports/[report_id]/edit/report-form.ts +++ /dev/null @@ -1,542 +0,0 @@ -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/reports/[report_id]/edit/report-form.types.ts b/src/routes/reports/[report_id]/edit/report-form.types.ts deleted file mode 100644 index a95e6b7c..00000000 --- a/src/routes/reports/[report_id]/edit/report-form.types.ts +++ /dev/null @@ -1,55 +0,0 @@ -export type SelectOption = { - disabled?: boolean; - label: string; - value: string; -}; - -export type ScheduleDraft = { - created_at: string; - dev_eui: string; - end_of_day: boolean; - end_of_week: boolean; - id: string; - is_active: boolean; - key: string; - report_id: string; - report_user_schedule_id: string; - user_id: string; -}; - -export type RecipientDraft = { - communication_method: string; - created_at: string; - email: string; - id: string; - key: string; - name: string; - report_id: string; - user_id: string; -}; - -export type DataProcessingScheduleDraft = { - crosses_midnight: boolean; - day_of_week: string; - end_time: string; - id: string; - is_enabled: boolean; - key: string; - report_id: string; - rule_type: string; - start_time: string; - timezone: string; -}; - -export type ReportDraft = { - created_at: string; - data_pull_interval: string; - dev_eui: string; - id: string; - name: string; - report_id: string; - report_data_processing_schedules: DataProcessingScheduleDraft[]; - report_recipients: RecipientDraft[]; - report_user_schedule: ScheduleDraft[]; - user_id: string; -}; diff --git a/src/routes/reports-new/alert-points-editor-text.ts b/src/routes/reports/alert-points-editor-text.ts similarity index 100% rename from src/routes/reports-new/alert-points-editor-text.ts rename to src/routes/reports/alert-points-editor-text.ts diff --git a/src/routes/reports-new/create/+page.server.ts b/src/routes/reports/create/+page.server.ts similarity index 100% rename from src/routes/reports-new/create/+page.server.ts rename to src/routes/reports/create/+page.server.ts diff --git a/src/routes/reports-new/create/+page.svelte b/src/routes/reports/create/+page.svelte similarity index 97% rename from src/routes/reports-new/create/+page.svelte rename to src/routes/reports/create/+page.svelte index 6a40dc64..22ae633b 100644 --- a/src/routes/reports-new/create/+page.svelte +++ b/src/routes/reports/create/+page.svelte @@ -15,7 +15,7 @@ - goto(resolve('/reports-new'))}> + goto(resolve('/reports'))}> ← {m.action_back()} diff --git a/src/routes/reports-new/data-pull-interval.ts b/src/routes/reports/data-pull-interval.ts similarity index 100% rename from src/routes/reports-new/data-pull-interval.ts rename to src/routes/reports/data-pull-interval.ts diff --git a/src/routes/reports-new/edit/[id]/+page.server.ts b/src/routes/reports/edit/[id]/+page.server.ts similarity index 100% rename from src/routes/reports-new/edit/[id]/+page.server.ts rename to src/routes/reports/edit/[id]/+page.server.ts diff --git a/src/routes/reports-new/edit/[id]/+page.svelte b/src/routes/reports/edit/[id]/+page.svelte similarity index 98% rename from src/routes/reports-new/edit/[id]/+page.svelte rename to src/routes/reports/edit/[id]/+page.svelte index 3d0b5132..d877a720 100644 --- a/src/routes/reports-new/edit/[id]/+page.svelte +++ b/src/routes/reports/edit/[id]/+page.svelte @@ -16,7 +16,7 @@ - goto(resolve('/reports-new'))}> + goto(resolve('/reports'))}> ← {m.action_back()} diff --git a/src/routes/reports/report-row.ts b/src/routes/reports/report-row.ts deleted file mode 100644 index bb80a002..00000000 --- a/src/routes/reports/report-row.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { ReportDto } from '$lib/api/api.dtos'; - -export type ReportDeviceRelations = { - cw_devices?: { - name?: string | null; - cw_device_owners?: Array<{ - user_id?: string | null; - permission_level?: number | null; - }> | null; - cw_locations?: { - name?: string | null; - location_id?: string | number | null; - } | null; - } | null; -}; - -export type ReportRow = ReportDto & - ReportDeviceRelations & { - device_name: string; - location_name: string; - permission_level: number | null; - }; diff --git a/src/routes/reports-new/report-template-form.ts b/src/routes/reports/report-template-form.ts similarity index 100% rename from src/routes/reports-new/report-template-form.ts rename to src/routes/reports/report-template-form.ts diff --git a/src/routes/rules-new/+page.svelte b/src/routes/rules-new/+page.svelte deleted file mode 100644 index 30650630..00000000 --- a/src/routes/rules-new/+page.svelte +++ /dev/null @@ -1,254 +0,0 @@ - - - - {m.rules_new_page_title()} - - - - goto(backHref(page.url, 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/[...path]/+page.server.ts b/src/routes/rules-new/[...path]/+page.server.ts new file mode 100644 index 00000000..b67205e7 --- /dev/null +++ b/src/routes/rules-new/[...path]/+page.server.ts @@ -0,0 +1,8 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +// The template-based rules pages took over /rules; keep old bookmarks working. +export const load: PageServerLoad = ({ params }) => { + const suffix = params.path ? `/${params.path}` : ''; + redirect(301, `/rules${suffix}`); +}; diff --git a/src/routes/rules-new/create/+page.server.ts b/src/routes/rules-new/create/+page.server.ts deleted file mode 100644 index 620a8be4..00000000 --- a/src/routes/rules-new/create/+page.server.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ApiService } from '$lib/api/api.service'; -import type { RuleFormContextDto } from '$lib/api/api.dtos'; -import type { PageServerLoad } from './$types'; - -const EMPTY_CONTEXT: RuleFormContextDto = { - devices: [], - locations: [], - actionTypes: [], - template: null -}; - -export const load: PageServerLoad = async ({ locals, fetch, url }) => { - const authToken = locals.jwtString ?? null; - const devEui = url.searchParams.get('dev_eui') ?? null; - - if (!authToken) { - return { context: EMPTY_CONTEXT, authToken, devEui }; - } - - const api = new ApiService({ fetchFn: fetch, authToken }); - const context = await api.getRuleFormContext().catch(() => EMPTY_CONTEXT); - - return { context, authToken, devEui }; -}; diff --git a/src/routes/rules-new/create/+page.svelte b/src/routes/rules-new/create/+page.svelte deleted file mode 100644 index ac0d5abb..00000000 --- a/src/routes/rules-new/create/+page.svelte +++ /dev/null @@ -1,40 +0,0 @@ - - - - {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 deleted file mode 100644 index a52f39fe..00000000 --- a/src/routes/rules-new/edit/[id]/+page.server.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ApiService, ApiServiceError } from '$lib/api/api.service'; -import type { RuleFormContextDto } from '$lib/api/api.dtos'; -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 context: RuleFormContextDto; - try { - context = await api.getRuleFormContext(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()); - } - - if (!context.template) { - error(404, m.rules_new_rule_template_not_found()); - } - - return { context, authToken }; -}; diff --git a/src/routes/rules-new/edit/[id]/+page.svelte b/src/routes/rules-new/edit/[id]/+page.svelte deleted file mode 100644 index 49253dab..00000000 --- a/src/routes/rules-new/edit/[id]/+page.svelte +++ /dev/null @@ -1,49 +0,0 @@ - - - - {m.rules_new_edit_page_title()} - - - - goto(resolve('/rules-new'))}> - ← {m.action_back()} - - -
-

{m.rules_new_edit_template()}

- -
- - -
- - diff --git a/src/routes/rules/+page.server.ts b/src/routes/rules/+page.server.ts deleted file mode 100644 index 59c27423..00000000 --- a/src/routes/rules/+page.server.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { PageServerLoad } from './$types'; - -// The rules list page uses CwDataTable with a client-side loadData callback that -// fetches, transforms, and paginates rules on demand. Pre-fetching here would -// duplicate that work and be thrown away immediately. The root layout supplies -// `authToken` and `session` to every route via layout data inheritance. -export const load: PageServerLoad = async () => { - return {}; -}; diff --git a/src/routes/rules/+page.svelte b/src/routes/rules/+page.svelte index c4aee119..af1c3a66 100644 --- a/src/routes/rules/+page.svelte +++ b/src/routes/rules/+page.svelte @@ -1,121 +1,180 @@ - {m.rules_page_title()} + {m.rules_new_page_title()} @@ -123,61 +182,73 @@ ← {m.action_back_to_dashboard()}
- + {#key tableKey} - - {#snippet cell(row: RuleRow, col: CwColumnDef, defaultValue: string)} - {#if col.key === 'created_at'} - {new Date(row.created_at).toLocaleString()} - {:else if col.key === 'last_triggered'} - {row.last_triggered - ? new Date(row.last_triggered).toLocaleString() - : m.common_not_available()} + {#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: RuleRow)} -
- {#if row.permission_level != null && row.permission_level <= 3} - - {/if} - {#if row.permission_level != null && row.permission_level <= 2} - goto(resolve('/rules/edit/[id]', { id: String(row.id) }))} - > - - - - {/if} + + {#snippet rowActions(row: RuleTemplateRow)} +
+ goto(resolve('/rules/edit/[id]', { id: String(row.id) }))} + > + + + +
{/snippet} + {#snippet toolbarActions()} - { - goto(resolve('/rules/create')); - }} - > - - {m.rules_create_new_rule()} + goto(resolve('/rules/create'))}> + {/snippet} {/key} + + diff --git a/src/routes/rules/DeleteRuleDialog.svelte b/src/routes/rules/DeleteRuleDialog.svelte deleted file mode 100644 index 459502db..00000000 --- a/src/routes/rules/DeleteRuleDialog.svelte +++ /dev/null @@ -1,64 +0,0 @@ - - - (open = true)}> - - - - (open = false)}> -

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

- {#snippet actions()} -
- (open = false)}> - - {m.action_cancel()} - - - {m.action_delete()} - -
- {/snippet} -
diff --git a/src/routes/rules-new/DeleteRuleTemplateDialog.svelte b/src/routes/rules/DeleteRuleTemplateDialog.svelte similarity index 100% rename from src/routes/rules-new/DeleteRuleTemplateDialog.svelte rename to src/routes/rules/DeleteRuleTemplateDialog.svelte diff --git a/src/routes/rules/RuleForm.svelte b/src/routes/rules/RuleForm.svelte deleted file mode 100644 index eeb88c86..00000000 --- a/src/routes/rules/RuleForm.svelte +++ /dev/null @@ -1,442 +0,0 @@ - - - - {#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-new/RuleTemplateForm.svelte b/src/routes/rules/RuleTemplateForm.svelte similarity index 99% rename from src/routes/rules-new/RuleTemplateForm.svelte rename to src/routes/rules/RuleTemplateForm.svelte index d3ec2765..80d69856 100644 --- a/src/routes/rules-new/RuleTemplateForm.svelte +++ b/src/routes/rules/RuleTemplateForm.svelte @@ -305,7 +305,7 @@ }); } - await goto(resolve('/rules-new')); + await goto(resolve('/rules')); } catch (error) { toast.add({ tone: 'danger', @@ -554,7 +554,7 @@ - goto(resolve('/rules-new'))} disabled={submitting}> + goto(resolve('/rules'))} disabled={submitting}> {m.action_cancel()} - import Icon from '$lib/components/Icon.svelte'; - import { AppNotice } from '$lib/components/layout'; - import { CwButton, CwChip, CwDialog, CwSeparator } from '@cropwatchdevelopment/cwui'; - import { - getRuleNotifierTypeOptions, - getRuleSendMethodOptions, - getRuleSubjectOptions - } from '$lib/i18n/options'; - import type { RulesDto } from '$lib/interfaces/rule.interface'; - import { m } from '$lib/paraglide/messages.js'; - import EYE_ICON from '$lib/images/icons/eye.svg'; - - type RuleRow = RulesDto & { - location_name?: string; - cw_devices?: Record | null; - }; - - let { row }: { row: RuleRow } = $props(); - let open = $state(false); - - const NOTIFIER_TYPES = getRuleNotifierTypeOptions(); - const SEND_METHODS = getRuleSendMethodOptions(); - const SUBJECT_OPTIONS = getRuleSubjectOptions(); - - let criteriaPreview = $derived( - (row.cw_rule_criteria ?? []).map( - (criterion) => - `${getCriterionSubjectLabel(criterion.subject)} ${criterion.operator} ${criterion.trigger_value}` - ) - ); - - function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); - } - - function getCriterionSubjectLabel(subject: string) { - return SUBJECT_OPTIONS.find((option) => option.value === subject)?.label ?? subject; - } - - function getNotifierLabel(notifierType: number) { - return ( - NOTIFIER_TYPES.find((option) => option.value === String(notifierType))?.label ?? - String(notifierType) - ); - } - - function getSendMethodLabel(sendUsing: string | null | undefined) { - if (!sendUsing) return ''; - return SEND_METHODS.find((option) => option.value === sendUsing)?.label ?? sendUsing; - } - - function getNotificationSummary(rule: RuleRow) { - const notifier = getNotifierLabel(rule.notifier_type); - const sendMethod = getSendMethodLabel(rule.send_using); - return sendMethod - ? `${notifier} (${sendMethod}) → ${rule.action_recipient}` - : `${notifier} → ${rule.action_recipient}`; - } - - function getDeviceLabel(rule: RuleRow) { - const deviceRecord = isRecord(rule.cw_devices) ? rule.cw_devices : null; - const deviceName = - typeof deviceRecord?.name === 'string' && deviceRecord.name.trim().length > 0 - ? deviceRecord.name - : ''; - const devEui = rule.dev_eui?.trim() ?? ''; - - if (deviceName && devEui) return `${deviceName} (${devEui})`; - return deviceName || devEui || '-'; - } - - function getResetValueLabel(resetValue: number | null | undefined) { - return resetValue === null || resetValue === undefined ? '-' : String(resetValue); - } - - - (open = true)}> - - - - -
- -
-
{m.common_name()}:
-
{row.name}
- -
{m.devices_device()}:
-
{getDeviceLabel(row)}
- -
{m.rules_trigger_count()}:
-
{row.trigger_count}
- -
{m.rules_notify_via()}:
-
{getNotificationSummary(row)}
- -
{m.rules_conditions()}:
-
- {#if criteriaPreview.length > 0} -
- {#each criteriaPreview as preview, i (i)} - - {/each} -
- {:else} - - - {/if} -
-
-
- - {#if row.cw_rule_criteria?.length} - - -
- {#each row.cw_rule_criteria as criterion, idx (criterion.id)} -
-
- - {m.rules_condition_number({ count: String(idx + 1) })} - -
- -
-
-
{m.rules_data_field()}:
-
{getCriterionSubjectLabel(criterion.subject)}
-
- -
-
{m.rules_operator()}:
-
{criterion.operator}
-
- -
-
{m.rules_trigger_value()}:
-
{criterion.trigger_value}
-
- -
-
{m.rules_reset_value()}:
-
{getResetValueLabel(criterion.reset_value)}
-
-
-
- {/each} -
- {/if} -
- - {#snippet actions()} - (open = false)}>{m.action_close()} - {/snippet} -
- - diff --git a/src/routes/rules/create/+page.server.ts b/src/routes/rules/create/+page.server.ts index 62486188..620a8be4 100644 --- a/src/routes/rules/create/+page.server.ts +++ b/src/routes/rules/create/+page.server.ts @@ -1,17 +1,24 @@ import { ApiService } from '$lib/api/api.service'; +import type { RuleFormContextDto } from '$lib/api/api.dtos'; import type { PageServerLoad } from './$types'; +const EMPTY_CONTEXT: RuleFormContextDto = { + devices: [], + locations: [], + actionTypes: [], + template: null +}; + export const load: PageServerLoad = async ({ locals, fetch, url }) => { const authToken = locals.jwtString ?? null; const devEui = url.searchParams.get('dev_eui') ?? null; if (!authToken) { - return { devices: [], devEui }; + return { context: EMPTY_CONTEXT, authToken, devEui }; } const api = new ApiService({ fetchFn: fetch, authToken }); + const context = await api.getRuleFormContext().catch(() => EMPTY_CONTEXT); - const devices = await api.getAllDevices().catch(() => []); - - return { devices, authToken, devEui }; + return { context, authToken, devEui }; }; diff --git a/src/routes/rules/create/+page.svelte b/src/routes/rules/create/+page.svelte index a1da38f5..8304f78d 100644 --- a/src/routes/rules/create/+page.svelte +++ b/src/routes/rules/create/+page.svelte @@ -1,13 +1,40 @@ - + + {m.rules_new_create_page_title()} + + + + goto(resolve('/rules'))}> + ← {m.action_back()} + + +
+

{m.rules_new_create_template()}

+
+ + +
+ + diff --git a/src/routes/rules/edit/[id]/+page.server.ts b/src/routes/rules/edit/[id]/+page.server.ts index da9471f8..a52f39fe 100644 --- a/src/routes/rules/edit/[id]/+page.server.ts +++ b/src/routes/rules/edit/[id]/+page.server.ts @@ -1,44 +1,37 @@ -import { ApiService } from '$lib/api/api.service'; +import { ApiService, ApiServiceError } from '$lib/api/api.service'; +import type { RuleFormContextDto } from '$lib/api/api.dtos'; 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 ruleId = parseInt(params.id, 10); + const userId = locals.jwt?.sub ?? null; + const templateId = Number(params.id); - if (!authToken) { + if (!authToken || !userId) { error(401, m.error_unauthorized_title()); } - if (isNaN(ruleId)) { - error(400, m.rules_invalid_rule_id()); + if (!Number.isInteger(templateId) || templateId <= 0) { + error(400, m.rules_new_invalid_template_id()); } const api = new ApiService({ fetchFn: fetch, authToken }); - - const [rawRule, rawDevices] = await Promise.all([ - api.getRule(ruleId).catch(() => null), - api.getAllDevices().catch(() => []) - ]); - - if (!rawRule) { - error(404, m.rules_rule_not_found()); + let context: RuleFormContextDto; + try { + context = await api.getRuleFormContext(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 rule = { - ...rawRule, - cw_rule_criteria: (rawRule.cw_rule_criteria ?? []).map((criterion) => { - const parsedId = typeof criterion.id === 'number' ? criterion.id : Number(criterion.id); - - return { - ...criterion, - id: Number.isFinite(parsedId) ? parsedId : 0 - }; - }) - }; - - const devices = rawDevices; + if (!context.template) { + error(404, m.rules_new_rule_template_not_found()); + } - return { rule, devices, authToken }; + return { context, authToken }; }; diff --git a/src/routes/rules/edit/[id]/+page.svelte b/src/routes/rules/edit/[id]/+page.svelte index 2ce7ea78..3aaa3141 100644 --- a/src/routes/rules/edit/[id]/+page.svelte +++ b/src/routes/rules/edit/[id]/+page.svelte @@ -1,8 +1,49 @@ - + + {m.rules_new_edit_page_title()} + + + + goto(resolve('/rules'))}> + ← {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/rule-template-alert-points.spec.ts similarity index 100% rename from src/routes/rules-new/rule-template-alert-points.spec.ts rename to src/routes/rules/rule-template-alert-points.spec.ts diff --git a/src/routes/rules-new/rule-template-alert-points.ts b/src/routes/rules/rule-template-alert-points.ts similarity index 100% rename from src/routes/rules-new/rule-template-alert-points.ts rename to src/routes/rules/rule-template-alert-points.ts