diff --git a/.vscode/mcp.json b/.vscode/mcp.json index 1f291d5d..bf436f29 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -1,4 +1,12 @@ { + "inputs": [ + { + "type": "promptString", + "id": "supabase-access-token", + "description": "Supabase personal access token", + "password": true + } + ], "servers": { "supabase": { "command": "npx", @@ -10,18 +18,6 @@ "SUPABASE_ACCESS_TOKEN": "${input:supabase-access-token}" }, "type": "stdio" - }, - "SVELTEKIT-mcp-server-70cd8977": { - "url": "https://mcp.svelte.dev/mcp", - "type": "http" - } - }, - "inputs": [ - { - "type": "promptString", - "id": "supabase-access-token", - "description": "Supabase personal access token", - "password": true } - ] + } } \ No newline at end of file diff --git a/SUPABAES_SETUP_SCRIPTS/TRIGGERS/alert_triggered_insert.sql b/SUPABAES_SETUP_SCRIPTS/TRIGGERS/alert_triggered_insert.sql new file mode 100644 index 00000000..f810bae0 --- /dev/null +++ b/SUPABAES_SETUP_SCRIPTS/TRIGGERS/alert_triggered_insert.sql @@ -0,0 +1,34 @@ +CREATE OR REPLACE FUNCTION public.fn_log_rule_trigger() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + -- Only handle rising edge: false -> true + IF COALESCE(OLD.is_triggered, false) = false + AND COALESCE(NEW.is_triggered, false) = true + THEN + -- -- Stamp and bump (commented out for now) + -- NEW.last_triggered := (now() AT TIME ZONE 'utc'); + -- NEW.trigger_count := COALESCE(OLD.trigger_count, 0) + 1; + + -- Insert log row if we have both NOT NULL values required by FK + IF NEW.dev_eui IS NOT NULL AND NEW."ruleGroupId" IS NOT NULL THEN + INSERT INTO public.cw_rule_triggered (dev_eui, rule_group_id) + VALUES (NEW.dev_eui, NEW."ruleGroupId"); + END IF; + END IF; + + RETURN NEW; +END; +$$; + + +DROP TRIGGER IF EXISTS trg_cw_rules_log_trigger ON public.cw_rules; + +CREATE TRIGGER trg_cw_rules_log_trigger +BEFORE UPDATE OF is_triggered ON public.cw_rules +FOR EACH ROW +WHEN (COALESCE(OLD.is_triggered, false) IS DISTINCT FROM COALESCE(NEW.is_triggered, false)) +EXECUTE FUNCTION public.fn_log_rule_trigger(); \ No newline at end of file diff --git a/database.types.ts b/database.types.ts index 333ca958..b5f2a38d 100644 --- a/database.types.ts +++ b/database.types.ts @@ -386,6 +386,63 @@ export type Database = { }; Relationships: []; }; + cw_air_data_duplicate: { + Row: { + battery_level: number | null; + co: number | null; + co2: number | null; + created_at: string; + dev_eui: string; + humidity: number | null; + is_simulated: boolean; + lux: number | null; + pressure: number | null; + rainfall: number | null; + smoke_detected: boolean | null; + temperature_c: number | null; + uv_index: number | null; + vape_detected: boolean | null; + wind_direction: number | null; + wind_speed: number | null; + }; + Insert: { + battery_level?: number | null; + co?: number | null; + co2?: number | null; + created_at?: string; + dev_eui: string; + humidity?: number | null; + is_simulated?: boolean; + lux?: number | null; + pressure?: number | null; + rainfall?: number | null; + smoke_detected?: boolean | null; + temperature_c?: number | null; + uv_index?: number | null; + vape_detected?: boolean | null; + wind_direction?: number | null; + wind_speed?: number | null; + }; + Update: { + battery_level?: number | null; + co?: number | null; + co2?: number | null; + created_at?: string; + dev_eui?: string; + humidity?: number | null; + is_simulated?: boolean; + lux?: number | null; + pressure?: number | null; + rainfall?: number | null; + smoke_detected?: boolean | null; + temperature_c?: number | null; + uv_index?: number | null; + vape_detected?: boolean | null; + wind_direction?: number | null; + wind_speed?: number | null; + }; + Relationships: []; + }; cw_data_metadata: { Row: { adder: number; @@ -422,6 +479,51 @@ export type Database = { }; Relationships: []; }; + cw_device_gateway: { + Row: { + created_at: string; + dev_eui: string; + gateway_id: string; + id: number; + last_update: string; + rssi: number | null; + snr: number | null; + }; + Insert: { + created_at?: string; + dev_eui: string; + gateway_id: string; + id?: number; + last_update?: string; + rssi?: number | null; + snr?: number | null; + }; + Update: { + created_at?: string; + dev_eui?: string; + gateway_id?: string; + id?: number; + last_update?: string; + rssi?: number | null; + snr?: number | null; + }; + Relationships: [ + { + foreignKeyName: 'cw_device_gateway_dev_eui_fkey'; + columns: ['dev_eui']; + isOneToOne: false; + referencedRelation: 'cw_devices'; + referencedColumns: ['dev_eui']; + }, + { + foreignKeyName: 'cw_device_gateway_gateway_id_fkey'; + columns: ['gateway_id']; + isOneToOne: false; + referencedRelation: 'cw_gateways'; + referencedColumns: ['gateway_id']; + } + ]; + }; cw_device_owners: { Row: { dev_eui: string; @@ -577,51 +679,60 @@ export type Database = { }; cw_devices: { Row: { - ai_provider: string | null; battery_changed_at: string | null; battery_level: number | null; dev_eui: string; installed_at: string | null; + last_data_updated_at: string | null; lat: number | null; location_id: number | null; long: number | null; name: string; + primary_data: number | null; report_endpoint: string | null; + secondary_data: number | null; serial_number: string | null; + tti_name: string | null; type: number | null; upload_interval: number | null; user_id: string | null; warranty_start_date: string | null; }; Insert: { - ai_provider?: string | null; battery_changed_at?: string | null; battery_level?: number | null; dev_eui: string; installed_at?: string | null; + last_data_updated_at?: string | null; lat?: number | null; location_id?: number | null; long?: number | null; name?: string; + primary_data?: number | null; report_endpoint?: string | null; + secondary_data?: number | null; serial_number?: string | null; + tti_name?: string | null; type?: number | null; upload_interval?: number | null; user_id?: string | null; warranty_start_date?: string | null; }; Update: { - ai_provider?: string | null; battery_changed_at?: string | null; battery_level?: number | null; dev_eui?: string; installed_at?: string | null; + last_data_updated_at?: string | null; lat?: number | null; location_id?: number | null; long?: number | null; name?: string; + primary_data?: number | null; report_endpoint?: string | null; + secondary_data?: number | null; serial_number?: string | null; + tti_name?: string | null; type?: number | null; upload_interval?: number | null; user_id?: string | null; @@ -958,6 +1069,42 @@ export type Database = { } ]; }; + cw_rule_triggered: { + Row: { + created_at: string; + dev_eui: string; + id: number; + rule_group_id: string; + }; + Insert: { + created_at?: string; + dev_eui: string; + id?: number; + rule_group_id: string; + }; + Update: { + created_at?: string; + dev_eui?: string; + id?: number; + rule_group_id?: string; + }; + Relationships: [ + { + foreignKeyName: 'cw_rule_triggered_dev_eui_fkey'; + columns: ['dev_eui']; + isOneToOne: false; + referencedRelation: 'cw_devices'; + referencedColumns: ['dev_eui']; + }, + { + foreignKeyName: 'cw_rule_triggered_rule_group_id_fkey'; + columns: ['rule_group_id']; + isOneToOne: false; + referencedRelation: 'cw_rules'; + referencedColumns: ['ruleGroupId']; + } + ]; + }; cw_rules: { Row: { action_recipient: string; @@ -1052,6 +1199,33 @@ export type Database = { }; Relationships: []; }; + cw_soil_data_duplicate: { + Row: { + created_at: string; + dev_eui: string; + ec: number | null; + moisture: number | null; + ph: number | null; + temperature_c: number | null; + }; + Insert: { + created_at?: string; + dev_eui: string; + ec?: number | null; + moisture?: number | null; + ph?: number | null; + temperature_c?: number | null; + }; + Update: { + created_at?: string; + dev_eui?: string; + ec?: number | null; + moisture?: number | null; + ph?: number | null; + temperature_c?: number | null; + }; + Relationships: []; + }; cw_traffic2: { Row: { bicycle_count: number; @@ -1331,6 +1505,7 @@ export type Database = { Row: { accepted_agreements: boolean; avatar_url: string | null; + created_at: string; discord: string | null; email: string | null; employer: string | null; @@ -1345,6 +1520,7 @@ export type Database = { Insert: { accepted_agreements?: boolean; avatar_url?: string | null; + created_at?: string; discord?: string | null; email?: string | null; employer?: string | null; @@ -1359,6 +1535,7 @@ export type Database = { Update: { accepted_agreements?: boolean; avatar_url?: string | null; + created_at?: string; discord?: string | null; email?: string | null; employer?: string | null; @@ -1761,41 +1938,57 @@ export type Database = { }; Returns: Json[]; }; - get_hloc_data: { - Args: - | { + get_hloc_data: + | { + Args: { device_eui: string; end_time: string; start_time: string; table_name: string; time_interval: string; - } - | { + }; + Returns: { + close: number; + high: number; + interval_time: string; + low: number; + open: number; + }[]; + } + | { + Args: { p_bucket_interval: string; p_dev_eui: string; p_metric: string; p_table: string; p_time_range: string; - } - | { + }; + Returns: { + bucket: string; + close_val: number; + dev_eui: string; + high_val: number; + low_val: number; + open_val: number; + }[]; + } + | { + Args: { p_bucket_interval: string; p_dev_eui: string; p_metric: string; p_time_range: string; - }; - Returns: { - bucket: string; - close_val: number; - dev_eui: string; - high_val: number; - low_val: number; - open_val: number; - }[]; - }; - get_location_for_user: { - Args: { user_id: string }; - Returns: number[]; - }; + }; + Returns: { + bucket: string; + close_val: number; + dev_eui: string; + high_val: number; + low_val: number; + open_val: number; + }[]; + }; + get_location_for_user: { Args: { user_id: string }; Returns: number[] }; get_road_events: { Args: { time_grouping: string }; Returns: { @@ -1816,6 +2009,29 @@ export type Database = { period_start: string; }[]; }; + get_table_columns: { + Args: { p_table: unknown }; + Returns: { + column_name: string; + }[]; + }; + is_device_admin_for: + | { + Args: { p_dev_eui: string }; + Returns: { + error: true; + } & 'Could not choose the best candidate function between: public.is_device_admin_for(p_dev_eui => text), public.is_device_admin_for(p_dev_eui => varchar). Try renaming the parameters or the function itself in the database so function overloading can be resolved'; + } + | { + Args: { p_dev_eui: string }; + Returns: { + error: true; + } & 'Could not choose the best candidate function between: public.is_device_admin_for(p_dev_eui => text), public.is_device_admin_for(p_dev_eui => varchar). Try renaming the parameters or the function itself in the database so function overloading can be resolved'; + }; + is_device_member_for: { Args: { p_dev_eui: string }; Returns: boolean }; + is_device_owner_for: { Args: { dev: string }; Returns: boolean }; + is_location_member_for: { Args: { loc_id: number }; Returns: boolean }; + is_location_owner_for: { Args: { loc_id: number }; Returns: boolean }; }; Enums: { [_ in never]: never; diff --git a/src/app.css b/src/app.css index a4288722..8d239c30 100644 --- a/src/app.css +++ b/src/app.css @@ -16,11 +16,11 @@ --color-primary-hover: #3a8c3f; --color-background: #eaeaea; --color-foreground: #938aee; - --color-foreground-light: #f0f0f0; + --color-foreground-light: #f5f5f5; --color-foreground-dark: #2a2a2a; --color-card: #ffffff; - --color-text: #333333; - --color-text-secondary: rgba(51, 51, 51, 0.7); + --color-text: #1f2937; + --color-text-secondary: rgba(31, 41, 55, 0.7); /* Specialized colors */ --color-air-bg: #e6f7ff; @@ -50,11 +50,15 @@ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); /* Standard UI colors */ - --color-border: rgb(229 231 235); - --color-border-subtle: rgb(229 231 235 / 0.3); + --color-border: rgb(219 224 231); + --color-border-subtle: rgb(229 231 235); --color-surface: rgb(249 250 251); --color-surface-raised: rgb(255 255 255); - --color-text-muted: rgb(107 114 128); + --color-text-muted: rgb(75 85 99); + --color-surface-muted: rgba(255, 255, 255, 0.85); + --color-surface-emphasis: color-mix(in srgb, var(--color-foreground) 6%, #ffffff 94%); + --color-header: color-mix(in srgb, var(--color-background) 80%, rgba(255, 255, 255, 0.8) 20%); + --color-sidebar: color-mix(in srgb, var(--color-background) 75%, rgba(255, 255, 255, 0.9) 25%); } /* Dark mode color variables (activated by .dark class on ) */ @@ -92,6 +96,10 @@ --color-surface: rgb(55 65 81); --color-surface-raised: rgb(55 65 81); --color-text-muted: rgb(156 163 175); + --color-surface-muted: rgba(31, 41, 55, 0.78); + --color-surface-emphasis: color-mix(in srgb, var(--color-foreground-light) 60%, rgba(15, 23, 42, 0.6) 40%); + --color-header: color-mix(in srgb, var(--color-background) 88%, rgba(15, 23, 42, 0.9) 12%); + --color-sidebar: color-mix(in srgb, var(--color-background) 82%, rgba(30, 41, 59, 0.92) 18%); } html, @@ -109,6 +117,15 @@ body { flex-grow: 1; } +body { + background: radial-gradient(circle at top, color-mix(in srgb, var(--color-background) 92%, #ffffff 8%) 0%, var(--color-background) 45%, var(--color-background) 100%); + color: var(--color-text); +} + +.dark body { + background: radial-gradient(circle at top, color-mix(in srgb, var(--color-background) 85%, #0f172a 15%) 0%, var(--color-background) 45%, var(--color-background) 100%); +} + /* Global card styles */ .card { background-color: var(--color-card); @@ -160,20 +177,70 @@ button.primary:hover { background-color: var(--color-primary-hover); } .btn-base { + display: inline-flex; + align-items: center; + justify-content: center; border-radius: var(--btn-radius); font-weight: var(--btn-font-weight); padding: var(--btn-padding-y) var(--btn-padding-x); box-shadow: var(--btn-shadow); - transition: background-color .2s, color .2s, border-color .2s; + transition: + background-color 180ms ease, + color 180ms ease, + border-color 180ms ease, + box-shadow 180ms ease, + transform 120ms ease; outline: none; - line-height: 1.25rem; + line-height: 1.3; + border: 1px solid transparent; +} +.btn-base:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} +.btn-base:active { + transform: translateY(1px); +} +.btn-base:disabled { + opacity: 0.6; + cursor: not-allowed; + box-shadow: none; +} +.btn-primary { + background: var(--btn-primary-bg); + color: var(--btn-primary-text); +} +.btn-primary:hover { + background: var(--btn-primary-bg-hover); +} +.btn-secondary { + background: var(--btn-secondary-bg); + color: var(--btn-secondary-text); +} +.btn-secondary:hover { + background: var(--btn-secondary-bg-hover); +} +.btn-ghost { + background: transparent; + color: inherit; +} +.btn-ghost:hover { + background-color: rgba(0 0 0 / 0.05); +} +.dark .btn-ghost:hover { + background-color: rgba(255 255 255 / 0.08); +} +.btn-outline { + background: transparent; + border: 1px solid var(--btn-secondary-bg-hover); + color: var(--btn-secondary-text); +} +.btn-outline:hover { + background-color: rgba(0 0 0 / 0.05); +} +.dark .btn-outline:hover { + background-color: rgba(255 255 255 / 0.08); } -.btn-primary { background: var(--btn-primary-bg); color: var(--btn-primary-text); } -.btn-primary:hover { background: var(--btn-primary-bg-hover); } -.btn-secondary { background: var(--btn-secondary-bg); color: var(--btn-secondary-text); } -.btn-secondary:hover { background: var(--btn-secondary-bg-hover); } -.btn-ghost { background: transparent; } -.btn-outline { background: transparent; border:1px solid var(--btn-secondary-bg-hover); } /* Sensor data specific styles */ @@ -192,6 +259,65 @@ button.primary:hover { background-color: var(--color-primary-hover); } color: var(--color-text-secondary); } +.surface-card { + background-color: color-mix(in srgb, var(--color-card) 96%, #ffffff 4%); + color: var(--color-text); + border-radius: 0.9rem; + border: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent 30%); + box-shadow: + 0 26px 38px -32px rgba(15 23 42 / 0.55), + 0 12px 28px -36px rgba(15 23 42 / 0.45), + 0 1px 3px rgba(15 23 42 / 0.12); + transition: + background-color 180ms ease, + border-color 180ms ease, + box-shadow 200ms ease; +} + +.surface-section { + background-color: color-mix(in srgb, var(--color-surface) 96%, #ffffff 4%); + border-radius: 0.9rem; + border: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent 30%); +} + +.surface-muted { + background-color: color-mix(in srgb, var(--color-surface-emphasis) 95%, #ffffff 5%); + border-radius: 0.9rem; +} + +.header-surface { + background-color: var(--color-header); + backdrop-filter: blur(14px); + border-bottom: 1px solid var(--color-border-subtle); + box-shadow: 0 8px 24px -20px rgba(15, 23, 42, 0.4); +} + +.sidebar-surface { + background-color: var(--color-sidebar); + backdrop-filter: blur(18px); + border-right: 1px solid var(--color-border-subtle); + box-shadow: 12px 0 28px -28px rgba(15, 23, 42, 0.45); +} + +.dark .surface-card { + background-color: color-mix(in srgb, var(--color-foreground-dark) 92%, rgba(15, 23, 42, 0.85) 8%); + border-color: color-mix(in srgb, var(--color-border) 65%, transparent 35%); +} + +.dark .surface-section { + background-color: color-mix(in srgb, var(--color-foreground-dark) 88%, rgba(15, 23, 42, 0.8) 12%); + border-color: color-mix(in srgb, var(--color-border) 65%, transparent 35%); +} + +.dark .surface-muted { + background-color: color-mix(in srgb, var(--color-foreground-dark) 75%, rgba(30, 41, 59, 0.9) 25%); +} + +.dark .header-surface, +.dark .sidebar-surface { + border-color: var(--color-border-subtle); +} + .error-message { color: var(--color-error); background-color: var(--color-error-bg); @@ -316,40 +442,23 @@ a { .form-container { display: flex; flex-direction: column; - gap: 1rem; + gap: 1.25rem; background-color: var(--color-card); - padding: 1.5rem; - border-radius: 0.5rem; + padding: 1.75rem; + border-radius: 0.75rem; + border: 1px solid var(--color-border-subtle); + box-shadow: var(--shadow-md); + transition: box-shadow 180ms ease; +} +.form-container:hover { box-shadow: - 0 4px 6px -1px rgb(0 0 0 / 0.1), - 0 2px 4px -2px rgb(0 0 0 / 0.1); + 0 14px 30px -20px rgb(15 23 42 / 0.35), + 0 8px 18px -12px rgb(15 23 42 / 0.2); } .form-container label { font-size: 0.875rem; font-weight: 500; -} -.form-container input, -.form-container textarea, -.form-container select { - border-radius: 0.25rem; - border-width: 1px; - border-color: rgb(209 213 219); - padding: 0.5rem; -} -.dark .form-container input, -.dark .form-container textarea, -.dark .form-container select { - border-color: rgb(55 65 81); -} -.form-container button[type='submit'], -.form-container input[type='submit'] { - color: white; - padding: 0.5rem 1rem; - border-radius: 0.25rem; -} -.form-container button[type='submit']:disabled, -.form-container input[type='submit']:disabled { - opacity: 0.5; + color: var(--color-text); } @@ -574,6 +683,7 @@ input[type="week"]:focus { /* Status/accent colors */ :root.force-light .bg-yellow-500 { background-color: #eab308 !important; } :root.force-light .bg-green-500 { background-color: #22c55e !important; } + :root.force-light .bg-red-400.dark\:bg-slate-500 { background-color: #f87171 !important; } /* White/transparent backgrounds */ :root.force-light .bg-white\/20 { background-color: rgba(255, 255, 255, 0.2) !important; } @@ -669,4 +779,4 @@ input[type="week"]:focus { line-height: 1rem; font-weight: 500; color: var(--color-text-muted); -} \ No newline at end of file +} diff --git a/src/app.d.ts b/src/app.d.ts index 38b659e5..8167efac 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -4,14 +4,16 @@ // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces -import type { Database } from '$lib/types/database.types'; +import type { Database } from '../database.types'; import type { Session, SupabaseClient, User } from '@supabase/supabase-js'; +type TypedSupabaseClient = SupabaseClient; + declare global { namespace App { // interface Error {} interface Locals { - supabase: SupabaseClient; + supabase: TypedSupabaseClient; safeGetSession: () => Promise<{ session: Session | null; user: User | null }>; session: Session | null; user: User | null; diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 94790b8d..7b042bee 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -2,6 +2,8 @@ import { error, redirect, type Handle } from '@sveltejs/kit'; import { createServerClient } from '@supabase/ssr'; import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'; import { createClient } from '@supabase/supabase-js'; +import type { Session, User } from '@supabase/supabase-js'; +import type { Database } from '../database.types'; const PUBLIC_ROUTES = [ '/offline.html', @@ -54,19 +56,23 @@ const handleCORS: Handle = async ({ event, resolve }) => { // Handle for Supabase authentication and session management const handleSupabase: Handle = async ({ event, resolve }) => { // Create a Supabase client specific for server-side rendering (SSR) - event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { - cookies: { - getAll: () => event.cookies.getAll(), - setAll: (cookiesToSet) => { - // Store cookies to be set later instead of setting them immediately - event.locals.supabaseCookies = cookiesToSet; + event.locals.supabase = createServerClient( + PUBLIC_SUPABASE_URL, + PUBLIC_SUPABASE_ANON_KEY, + { + cookies: { + getAll: () => event.cookies.getAll(), + setAll: (cookiesToSet) => { + // Store cookies to be set later instead of setting them immediately + event.locals.supabaseCookies = cookiesToSet; + } } } - }); + ) as any; // Handle JWT token authentication for API routes - let tokenSession = null; - let tokenUser = null; + let tokenSession: Session | null = null; + let tokenUser: User | null = null; // Get headers from the request for better debugging const headers = new Headers(event.request.headers); @@ -84,17 +90,32 @@ const handleSupabase: Handle = async ({ event, resolve }) => { event.url.pathname.startsWith('/api') || event.url.pathname.includes('/reports/pdf'); if (jwt && isApiOrAppRoute) { - const jwt = authorizationHeader?.replace(/^Bearer\s+/i, '').trim(); - if (!jwt) { + const bearer = authorizationHeader?.replace(/^Bearer\s+/i, '').trim(); + if (!bearer) { throw error(401, 'Unauthorized access: No JWT token provided'); } - const jwtSupabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { + const jwtSupabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { global: { - headers: { Authorization: `Bearer ${jwt}` } + headers: { Authorization: `Bearer ${bearer}` } }, auth: { persistSession: false } }); - event.locals.supabase = jwtSupabase; + + const [{ data: sessionData, error: sessionError }, { data: userData, error: userError }] = + await Promise.all([jwtSupabase.auth.getSession(), jwtSupabase.auth.getUser()]); + + if (sessionError) { + console.error('JWT session lookup error:', sessionError.message); + } + + if (userError || !userData?.user) { + throw error(401, 'Unauthorized access: Invalid JWT token'); + } + + tokenSession = sessionData.session ?? null; + tokenUser = userData.user; + + event.locals.supabase = jwtSupabase as any; return await resolve(event, { filterSerializedResponseHeaders(name) { return name === 'content-range' || name === 'x-supabase-api-version'; @@ -184,6 +205,8 @@ const handleSupabase: Handle = async ({ event, resolve }) => { // Token is valid, set the user from the validated token //console.log('Valid API token for user:', data.user.email); event.locals.user = data.user; + tokenUser = data.user; + tokenSession = null; // Continue processing the request const response = await resolve(event, { diff --git a/src/lib/components/BatteryLevel.svelte b/src/lib/components/BatteryLevel.svelte index 3aba1de1..35bccdf9 100644 --- a/src/lib/components/BatteryLevel.svelte +++ b/src/lib/components/BatteryLevel.svelte @@ -4,16 +4,27 @@ import { Tooltip } from 'bits-ui'; // Props + type IconSize = 'small' | 'medium' | 'large' | 'xlarge'; + let { value = 50, // 0..100 - size = 'medium', + size = 'medium' as IconSize, showLabel = false, // show % text next to icon charging = false, // optional charging bolt overlay lowThreshold = 15, // % -> red midThreshold = 40, // % -> amber highThreshold = 70, // % -> yellow; above is green ariaLabel = 'Battery level' - } = $props(); + } = $props<{ + value?: number; + size?: IconSize; + showLabel?: boolean; + charging?: boolean; + lowThreshold?: number; + midThreshold?: number; + highThreshold?: number; + ariaLabel?: string; + }>(); function getBatteryColor(): string { if (value <= lowThreshold) return 'red'; diff --git a/src/lib/components/CopyButton.svelte b/src/lib/components/CopyButton.svelte index 1e01f1e2..9ad15107 100644 --- a/src/lib/components/CopyButton.svelte +++ b/src/lib/components/CopyButton.svelte @@ -1,6 +1,7 @@ -
-
-
+
+
+
-
-

{$_('Sensor Data - Today')}

+
+

{$_('Sensor Data - Today')}

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

{$_('No data available for today')}

+
+

{$_('No data available for today')}

{:else} {#each visibleColumns as column} @@ -88,15 +98,11 @@ - + {#each filteredData as row, rowIndex} - + {#each visibleColumns as column} - {/each} diff --git a/src/lib/components/GlobalSidebar.svelte b/src/lib/components/GlobalSidebar.svelte index 70ae1b74..5fd4c2bd 100644 --- a/src/lib/components/GlobalSidebar.svelte +++ b/src/lib/components/GlobalSidebar.svelte @@ -140,33 +140,21 @@
{$_(column)}
+ {formatCellValue(column, row[column])} - {report.dev_eui ?? $_('N/A')} + + {report.cw_device?.name ?? $_('N/A')} + ({report.dev_eui ?? $_('N/A')}) + {formatDate(report.created_at)} diff --git a/src/routes/app/dashboard/+page.svelte b/src/routes/app/dashboard/+page.svelte index f4d0a68a..8d10967e 100644 --- a/src/routes/app/dashboard/+page.svelte +++ b/src/routes/app/dashboard/+page.svelte @@ -648,10 +648,8 @@ overflow: auto; /* background-color: var(--color-card); */ border-radius: 0.5rem; - padding: 1rem; - box-shadow: - 0 4px 6px -1px rgba(0, 0, 0, 0.1), - 0 2px 4px -1px rgba(0, 0, 0, 0.06); + padding: 1.25rem; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.08); } /* @media (max-width: 768px) { diff --git a/src/routes/app/dashboard/location/$types.ts b/src/routes/app/dashboard/location/$types.ts deleted file mode 100644 index f69d72b7..00000000 --- a/src/routes/app/dashboard/location/$types.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { PageLoad, PageServerLoad } from './$types.js'; - -export interface LocationWithDevices { - location_id: number; - name: string; - description?: string | null; - lat?: number | null; - long?: number | null; - owner_id?: string | null; - created_at: string; - map_zoom?: number | null; - cw_devices?: DeviceBasic[]; -} - -export interface DeviceBasic { - dev_eui: string; - name: string; - device_type?: string | null; - lat?: number | null; - long?: number | null; - created_at: string; -} - -export interface PageData { - locations: LocationWithDevices[]; -} - -export type { PageLoad, PageServerLoad }; diff --git a/src/routes/app/dashboard/location/+page.server.ts b/src/routes/app/dashboard/location/+page.server.ts index bda6098b..1b050e5f 100644 --- a/src/routes/app/dashboard/location/+page.server.ts +++ b/src/routes/app/dashboard/location/+page.server.ts @@ -2,59 +2,82 @@ import type { PageServerLoad } from './$types'; import { redirect } from '@sveltejs/kit'; export interface LocationWithDevices { - location_id: number; - name: string; - description?: string | null; - lat?: number | null; - long?: number | null; - owner_id?: string | null; - created_at: string; - map_zoom?: number | null; - cw_devices?: DeviceBasic[]; + location_id: number; + name: string; + description?: string | null; + lat?: number | null; + long?: number | null; + owner_id?: string | null; + created_at: string; + map_zoom?: number | null; + cw_devices?: DeviceBasic[]; } export interface DeviceBasic { - dev_eui: string; - name: string; - device_type?: string | null; - lat?: number | null; - long?: number | null; - created_at: string; + dev_eui: string; + name: string; + device_type?: string | null; + lat?: number | null; + long?: number | null; + created_at: string; } -export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession } }) => { - const session = await safeGetSession(); - - if (!session || !session.user) { - throw redirect(303, '/auth/signin'); - } - - try { - // Fetch all locations for the user - const { data: locations, error } = await supabase - .from('cw_locations') - .select(` +export const load = (async (event) => { + const { supabase, safeGetSession } = event.locals; + const { session, user } = await safeGetSession(); + + if (!session || !user) { + throw redirect(303, '/auth/signin'); + } + + try { + // Fetch all locations for the user + const { data: locations, error } = await supabase + .from('cw_locations') + .select( + ` *, cw_devices(*) - ) - `) - .eq('owner_id', session.user.id) - .order('name'); - - if (error) { - console.error('Error fetching locations:', error); - return { - locations: [] - }; - } - - return { - locations: (locations || []) as LocationWithDevices[] - }; - } catch (err) { - console.error('Error in load function:', err); - return { - locations: [] as LocationWithDevices[] - }; - } -}; + ` + ) + .eq('owner_id', user.id) + .order('name'); + + if (error) { + console.error('Error fetching locations:', error); + return { + locations: [] + }; + } + + const normalizedLocations: LocationWithDevices[] = (locations ?? []).map((loc: any) => ({ + location_id: loc.location_id, + name: loc.name, + description: loc.description, + lat: loc.lat, + long: loc.long, + owner_id: loc.owner_id, + created_at: loc.created_at, + map_zoom: loc.map_zoom, + cw_devices: Array.isArray(loc.cw_devices) + ? loc.cw_devices.map((device: any) => ({ + dev_eui: device.dev_eui, + name: device.name, + device_type: device.device_type ?? null, + lat: device.lat, + long: device.long, + created_at: device.created_at ?? '' + })) + : [] + })); + + return { + locations: normalizedLocations + }; + } catch (err) { + console.error('Error in load function:', err); + return { + locations: [] as LocationWithDevices[] + }; + } +}) satisfies PageServerLoad; diff --git a/src/routes/app/dashboard/location/+page.svelte b/src/routes/app/dashboard/location/+page.svelte index a6d568e5..dfbdeae4 100644 --- a/src/routes/app/dashboard/location/+page.svelte +++ b/src/routes/app/dashboard/location/+page.svelte @@ -10,7 +10,7 @@ mdiSortAscending, mdiSortDescending, mdiMagnify - } from '@mdi/js'; + } from '$lib/icons/mdi'; import Button from '$lib/components/ui/base/Button.svelte'; import Card from '$lib/components/ui/base/Card.svelte'; import Icon from '$lib/components/ui/base/Icon.svelte'; diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/reports/pdf/+server.ts b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/reports/pdf/+server.ts index 746b848a..967ddbaa 100644 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/reports/pdf/+server.ts +++ b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/reports/pdf/+server.ts @@ -1,3 +1,4 @@ +import type { TableRow } from '$lib/pdf'; import { createPDFDataTable } from '$lib/pdf/pdfDataTable'; import { DeviceDataService } from '$lib/services/DeviceDataService'; import { SessionService } from '$lib/services/SessionService'; @@ -112,8 +113,6 @@ export const GET: RequestHandler = async ({ align: 'left' }); - // Generate sample data for the table - const dataa: { date: string; values: [] }[] = []; ////////////////////////////////////////////////////////////////// const tokyoNow = DateTime.now().setZone('Asia/Tokyo'); const startDate = tokyoNow.minus({ months: 1 }).startOf('month').toUTC().toJSDate(); const endDate = tokyoNow.minus({ months: 1 }).endOf('month').toUTC().toJSDate(); @@ -129,34 +128,65 @@ export const GET: RequestHandler = async ({ throw error(404, 'No data found for the specified device'); } - // Transform DeviceDataRecord[] to TableData[] format - const data = deviceData.map((record) => ({ - date: record.created_at, - values: Object.entries(record) - .filter( - ([key, value]) => - key !== 'dev_eui' && key !== 'created_at' && typeof value === 'number' && value !== null + // Determine numeric keys across dataset + const numericKeys = Array.from( + new Set( + deviceData.flatMap((record) => + Object.entries(record as Record) + .filter( + ([key, value]) => + key !== 'dev_eui' && key !== 'created_at' && typeof value === 'number' + ) + .map(([key]) => key) ) - .map(([_, value]) => value as number) - })); + ) + ).sort(); - if (!data || data.length === 0) { - throw error(404, 'No data found for the specified device'); + if (numericKeys.length === 0) { + throw error(404, 'No numeric data found for the specified device'); } - // Get alert points for the device - const alertPoints = await deviceDataService.getAlertPointsForDevice(devEui); - - // Create the alert data structure - const alertData = { - alert_points: alertPoints, - created_at: new Date().toISOString(), - dev_eui: devEui, - id: 1, - name: 'Device Report', - report_id: '1' + const dataHeaderTable: TableRow = { + header: { label: '日時', width: 70 }, + cells: numericKeys.map((key) => ({ + label: key, + width: 60 + })) }; - createPDFDataTable(doc, data, alertData); + + const dataRowsTable: TableRow[] = deviceData.map((record) => { + const recordData = record as Record; + const createdAt = record.created_at ?? recordData.created_at; + const timestampLabel = + typeof createdAt === 'string' + ? DateTime.fromISO(createdAt, { zone: 'utc' }) + .setZone('Asia/Tokyo') + .toFormat('yyyy-MM-dd HH:mm') + : DateTime.now().setZone('Asia/Tokyo').toFormat('yyyy-MM-dd HH:mm'); + + return { + header: { + label: timestampLabel, + value: createdAt, + width: 70 + }, + cells: numericKeys.map((key) => { + const value = recordData[key]; + return { + label: key, + value: typeof value === 'number' ? value : undefined, + width: 60 + }; + }) + }; + }); + + createPDFDataTable({ + doc, + dataHeader: dataHeaderTable, + dataRows: dataRowsTable, + config: { timezone: 'Asia/Tokyo' } + }); // Add generation timestamp doc diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/+page.server.ts b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/+page.server.ts index 892559ed..8e91cf22 100644 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/+page.server.ts +++ b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/+page.server.ts @@ -96,5 +96,55 @@ export const actions: Actions = { console.error('Error updating device settings:', err); return { success: false, error: 'Internal Server Error' }; } + }, + deleteDevice: async ({ params, locals }) => { + const { devEui } = params; + + if (!devEui) { + return { success: false, error: 'Device EUI is required' }; + } + + const sessionService = new SessionService(locals.supabase); + const { session, user } = await sessionService.getSafeSession(); + + if (!session || !user) { + return { success: false, error: 'Authentication required' }; + } + + const errorHandler = new ErrorHandlingService(); + const deviceRepository = new DeviceRepository(locals.supabase, errorHandler); + const deviceService = new DeviceService(deviceRepository); + const deviceOwnersRepository = new DeviceOwnersRepository(locals.supabase, errorHandler); + + const device = await deviceService.getDeviceByEui(devEui); + + if (!device) { + return { success: false, error: 'Device not found' }; + } + + const owners = await deviceOwnersRepository.findByDeviceEui(devEui); + const isOwner = + device.user_id === user.id || + owners.some((owner) => owner.user_id === user.id && owner.permission_level === 1); + + if (!isOwner) { + return { success: false, error: 'Unauthorized to delete this device' }; + } + + try { + const deleted = await deviceService.deleteDevice(devEui); + + if (!deleted) { + return { success: false, error: 'Failed to delete device' }; + } + + return { success: true }; + } catch (err) { + console.error('Error deleting device:', err); + return { + success: false, + error: err instanceof Error ? err.message : 'Internal Server Error' + }; + } } }; diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/+page.svelte b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/+page.svelte index 02fcb1c1..4c1d4f26 100644 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/+page.svelte +++ b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/+page.svelte @@ -1,6 +1,5 @@ @@ -57,7 +33,7 @@
-
+

{$_('General')}

@@ -168,7 +144,7 @@
{#if isOwner} -
+

{$_('Dangerous Zone')}

- +
+ +
{ + if (result.type === 'success' && result.data.success) { + showDeleteDialog = false; + success($_('Device removed successfully.')); + window.location.assign('/app/dashboard'); + } else { + error( + result.type === 'failure' && result.data?.error + ? result.data.error + : $_('Failed to delete device.') + ); + } + }} + > + +
+
{/snippet}
diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/create/+page.server.ts b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/create/+page.server.ts index 090951a6..34783680 100644 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/create/+page.server.ts +++ b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/create/+page.server.ts @@ -140,13 +140,19 @@ export const actions: Actions = { const formData = await request.formData(); // const name = formData.get('name') as string; - const name = formData.get('name'); // 'abc' - const nameData = { name }; // { name: 'abc' } + const nameEntry = formData.get('name'); + if (typeof nameEntry !== 'string' || !nameEntry.trim()) { + return fail(400, { success: false, error: 'Report name is required' }); + } + const name = nameEntry.trim(); + const nameData = { name }; - const reportId = formData.get('reportId') as string; // For editing existing reports - const alertPointsJson = formData.get('alertPoints') as string; - const recipientsJson = formData.get('recipients') as string; - const schedulesJson = formData.get('schedules') as string; + const reportIdEntry = formData.get('reportId'); + const reportId = + typeof reportIdEntry === 'string' && reportIdEntry.length > 0 ? reportIdEntry : undefined; + const alertPointsJson = formData.get('alertPoints'); + const recipientsJson = formData.get('recipients'); + const schedulesJson = formData.get('schedules'); if (!name) { return fail(400, { success: false, error: 'Report name is required' }); @@ -157,9 +163,18 @@ export const actions: Actions = { let schedules = []; try { - alertPoints = alertPointsJson ? JSON.parse(alertPointsJson) : []; - recipients = recipientsJson ? JSON.parse(recipientsJson) : []; - schedules = schedulesJson ? JSON.parse(schedulesJson) : []; + alertPoints = + typeof alertPointsJson === 'string' && alertPointsJson.length > 0 + ? JSON.parse(alertPointsJson) + : []; + recipients = + typeof recipientsJson === 'string' && recipientsJson.length > 0 + ? JSON.parse(recipientsJson) + : []; + schedules = + typeof schedulesJson === 'string' && schedulesJson.length > 0 + ? JSON.parse(schedulesJson) + : []; } catch (parseError) { return fail(400, { success: false, error: 'Invalid data format' }); } @@ -197,7 +212,7 @@ export const actions: Actions = { } else { // Create new report report = await reportService.createReport({ - name, + name: nameData.name, dev_eui: devEui }); } diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/create/+page.svelte b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/create/+page.svelte index 8ec1b4e2..9f8e3e34 100644 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/create/+page.svelte +++ b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/reports/create/+page.svelte @@ -10,144 +10,154 @@ import { success as toastSuccess, error as toastError } from '$lib/stores/toast.svelte'; import type { ReportAlertPoint } from '$lib/models/Report.js'; import type { ActionResult } from '@sveltejs/kit'; - import { untrack } from 'svelte'; import { _ } from 'svelte-i18n'; import CopyButton from '$lib/components/CopyButton.svelte'; let { data, form } = $props(); - // Extract data properties + type AlertPointState = { + id: string; + name: string; + operator: '=' | '>' | '<' | 'range' | 'null'; + value?: number; + min?: number; + max?: number; + data_point_key?: string; + hex_color: string; + }; + + type RecipientState = { + id: string; + email: string; + name: string; + }; + + type ScheduleState = { + id: string; + frequency: 'daily' | 'weekly' | 'monthly'; + time: string; + days?: number[]; + }; + + const getColorHistory = (source: unknown): string[] => { + const base = Array.isArray(source) ? source : (source as { data?: unknown })?.data; + if (!Array.isArray(base)) return []; + return base + .map((item) => (item as { hex_color?: string })?.hex_color) + .filter((color): color is string => typeof color === 'string' && color.length); + }; + + const normalizeAlertPoints = (source: unknown): AlertPointState[] => { + if (!Array.isArray(source)) return []; + return source.map((point: any) => ({ + id: point.id?.toString() || crypto.randomUUID(), + name: point.name || '', + operator: + point.operator === null || point.operator === 'null' + ? 'null' + : point.operator === 'range' + ? 'range' + : point.operator || ('=' as '=' | '>' | '<' | 'range'), + data_point_key: point.data_point_key || '', + min: point.min ?? undefined, + max: point.max ?? undefined, + value: point.value ?? undefined, + hex_color: point.hex_color || '#3B82F6' + })); + }; + + const normalizeRecipients = (source: unknown): RecipientState[] => { + if (!Array.isArray(source)) return []; + return source.map((recipient: any) => ({ + id: recipient.id?.toString() || crypto.randomUUID(), + email: recipient.email || '', + name: recipient.name || '' + })); + }; + + const normalizeSchedules = (source: unknown): ScheduleState[] => { + if (!Array.isArray(source)) return []; + return source.map((schedule: any) => ({ + id: schedule.id?.toString() || crypto.randomUUID(), + frequency: schedule.end_of_week ? 'weekly' : schedule.end_of_month ? 'monthly' : 'daily', + time: schedule.time || '09:00', + days: schedule.days || [] + })); + }; + const devEui = $derived(data.devEui); const locationId = $derived(data.locationId); const report = $derived(data.report); const isEditing = $derived(data.isEditing); const recipientsData = $derived(data.recipients); const dataKeys = $derived(data.dataKeys); - const alertPointsColorHistory = $derived(data.previouslyUsedAlertColors); - // Form state - let reportName = $state(''); + const initialColorHistory = getColorHistory(data.previouslyUsedAlertColors); + let alertPointsColorHistory = $state(initialColorHistory); + + const initialReportName = isEditing && report ? report.name || '' : ''; + let reportName = $state(initialReportName); let isSubmitting = $state(false); let showErrors = $state(false); let formEl: HTMLFormElement; - // Alert points state - using $state for deep reactivity - let alertPoints = $state< - Array<{ - id: string; - name: string; - operator: '=' | '>' | '<' | 'range' | 'null'; // Allow both - value?: number; - min?: number; - max?: number; - data_point_key?: string; - hex_color: string; - }> - >([]); - - // Recipients state - let recipients = $state< - Array<{ - id: string; - email: string; - name: string; - }> - >([]); - - // Schedules state - let schedules = $state< - Array<{ - id: string; - frequency: 'daily' | 'weekly' | 'monthly'; - time: string; - days?: number[]; - }> - >([]); - - // Initialize form data from loaded report if editing - $effect(() => { - if (isEditing && report) { - untrack(() => { - reportName = report.name || ''; - }); - } + const initialAlertPoints = isEditing ? normalizeAlertPoints(data.alertPoints) : []; + const initialRecipients = isEditing ? normalizeRecipients(recipientsData) : []; + const initialSchedules = isEditing ? normalizeSchedules(data.schedules) : []; - if (previouslyUsedAlertColors) { - untrack(() => { - previouslyUsedAlertColors.splice( - 0, - previouslyUsedAlertColors.length, - ...data.previouslyUsedAlertColors.data - .map((item: any) => item.hex_color) - .filter((color: string) => color) - ); - }); - } + let alertPoints = $state(initialAlertPoints); + let recipients = $state(initialRecipients); + let schedules = $state(initialSchedules); - if (isEditing && data.alertPoints) { - untrack(() => { - alertPoints.splice( - 0, - alertPoints.length, - ...data.alertPoints.map((point: any) => ({ - id: point.id?.toString() || crypto.randomUUID(), - name: point.name || '', - operator: - // Normalize null values to 'null' string - point.operator === null || point.operator === 'null' - ? 'null' - : point.operator === 'range' - ? 'range' - : point.operator || ('=' as '=' | '>' | '<' | 'range'), - data_point_key: point.data_point_key || '', - min: point.min ?? undefined, - max: point.max ?? undefined, - value: point.value ?? undefined, - hex_color: point.hex_color || '#3B82F6' - })) - ); - }); - } + const validationErrors = $derived(() => { + const errors: string[] = []; + const ranges: Array<{ start: number; end: number; name: string }> = []; - if (isEditing && recipientsData) { - untrack(() => { - recipients.splice( - 0, - recipients.length, - ...data.recipients.map((recipient: any) => ({ - id: recipient.id?.toString() || crypto.randomUUID(), - email: recipient.email || '', - name: recipient.name || '' - })) - ); - }); - } + alertPoints.forEach((point) => { + if (point.operator === 'null' || point.operator === null || !point.operator) { + return; + } - if (isEditing && data.schedules) { - untrack(() => { - schedules.splice( - 0, - schedules.length, - ...data.schedules.map((schedule: any) => { - let frequency: 'daily' | 'weekly' | 'monthly' = schedule.end_of_week - ? 'weekly' - : schedule.end_of_month - ? 'monthly' - : 'daily'; - return { - id: schedule.id?.toString() || crypto.randomUUID(), - frequency, - time: schedule.time || '09:00', - days: schedule.days || [] - }; - }) - ); - }); - } - }); + const value = Number(point.value); + const min = Number(point.min); + const max = Number(point.max); + + let start: number; + let end: number; + + if (point.operator === '=') { + if (isNaN(value)) return; + start = end = value; + } else if (point.operator === 'range') { + if (isNaN(min) || isNaN(max)) return; + start = min; + end = max; + } else if (point.operator === '>') { + if (isNaN(value)) return; + start = value; + end = Infinity; + } else if (point.operator === '<') { + if (isNaN(value)) return; + start = -Infinity; + end = value; + } else { + return; + } - // Validation errors - let validationErrors = $state([]); + for (const existingRange of ranges) { + if ( + (start <= existingRange.end && end >= existingRange.start) || + (existingRange.start <= end && existingRange.end >= start) + ) { + errors.push(`"${point.name}" overlaps with "${existingRange.name}"`); + } + } + + ranges.push({ start, end, name: point.name }); + }); + + return errors; + }); // Color palette for alert points const colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899']; @@ -205,69 +215,7 @@ } } - // Validation logic - function validateRanges() { - validationErrors.splice(0, validationErrors.length); - - // Check for overlapping ranges - const ranges: Array<{ start: number; end: number; name: string }> = []; - - alertPoints.forEach((point) => { - debugger; - // Exclude Display Only points from validation - check for both 'null' string and null value - if (point.operator === 'null' || point.operator === null || !point.operator) { - console.log(`Skipping validation for Display Only point: ${point.name}`); - return; - } - - const value = Number(point.value); - const min = Number(point.min); - const max = Number(point.max); - - let start: number, end: number; - - if (point.operator === '=') { - if (isNaN(value)) return; - start = end = value; - } else if (point.operator === 'range') { - if (isNaN(min) || isNaN(max)) return; - start = min; - end = max; - } else if (point.operator === '>') { - if (isNaN(value)) return; - start = value; - end = Infinity; - } else if (point.operator === '<') { - if (isNaN(value)) return; - start = -Infinity; - end = value; - } else { - return; // Skip invalid points - } - - // Check for overlaps with existing ranges - for (const existingRange of ranges) { - if ( - (start <= existingRange.end && end >= existingRange.start) || - (existingRange.start <= end && existingRange.end >= start) - ) { - validationErrors.push(`"${point.name}" overlaps with "${existingRange.name}"`); - } - } - - // ranges.push({ start, end, name: point.name }); - }); - } - - // Watch for changes to alert points - $effect(() => { - // Only track alertPoints changes, not validationErrors changes - const points = alertPoints; - // Use untrack to prevent reactive loops when updating validationErrors - untrack(() => { - validateRanges(); - }); - }); + // Validation errors computed from alert points // Derived number line points for visualization const numberLinePoints = $derived( diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/rules/+page.server.ts b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/rules/+page.server.ts index 77f0cfcc..457a4b0c 100644 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/rules/+page.server.ts +++ b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/rules/+page.server.ts @@ -267,8 +267,11 @@ export const actions: Actions = { } else { // Create new criteria for this rule const newCriteriaInsert: RuleCriteriaInsert = { - ...criteriaData, - ruleGroupId: updatedRule.ruleGroupId + ruleGroupId: updatedRule.ruleGroupId, + subject: item.subject, + operator: item.operator, + trigger_value: item.trigger_value, + reset_value: item.reset_value }; const createdCriteria = await ruleService.createRuleCriteria(newCriteriaInsert); if (!createdCriteria) { diff --git a/src/routes/app/dashboard/location/[location_id]/devices/create/+page.server.ts b/src/routes/app/dashboard/location/[location_id]/devices/create/+page.server.ts index c4004f6a..93299146 100644 --- a/src/routes/app/dashboard/location/[location_id]/devices/create/+page.server.ts +++ b/src/routes/app/dashboard/location/[location_id]/devices/create/+page.server.ts @@ -14,141 +14,162 @@ import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; import type { SupabaseClient } from '@supabase/supabase-js'; interface PageData { - currentUser: AuthUser; - usersInLocation: LocationUser[]; - permissionTypes: Array<[string, PermissionLevel]>; - locationId: number; - deviceTypes: Array<{ id: number; name: string }>; + currentUser: AuthUser; + usersInLocation: LocationUser[]; + permissionTypes: Array<[string, PermissionLevel]>; + locationId: number; + deviceTypes: Array<{ id: number; name: string }>; } export const load: PageServerLoad = async (event) => { - const { params, locals } = event; - const session = locals.session as Session | null; // Assuming session is directly on locals - - if (!session?.user) { - throw fail(401, { message: 'Unauthorized' }); - } - - const currentUser = session.user; - const location_id = parseInt(params.location_id || '0', 10); - - if (!location_id) { - throw fail(400, { message: 'Invalid location ID' }); - } - - const supabase = locals.supabase as SupabaseClient; - const errorHandler = new ErrorHandlingService(); - const locationRepository = new LocationRepository(supabase, errorHandler); - const deviceRepository = new DeviceRepository(supabase, errorHandler); - const locationService = new LocationService(locationRepository, deviceRepository); - const deviceTypeRepository = new DeviceTypeRepository(supabase, errorHandler); - - - try { - const usersInLocation = await locationService.getLocationUsers(location_id); - const deviceTypes = await deviceTypeRepository.findAll(); - const permissionTypes = Object.entries(PermissionLevel) - .filter(([key]) => isNaN(Number(key))) // Get string keys from enum - .map(([key, value]) => [key, value as PermissionLevel]); - - return { - currentUser, - usersInLocation: usersInLocation.filter(u => u.user_id !== currentUser.id), - permissionTypes, - locationId: location_id, - deviceTypes, - } as PageData; - } catch (error) { - console.error('Error loading data for create device page:', error); - throw fail(500, { message: 'Failed to load page data. ' + (error instanceof Error ? error.message : 'Unknown error') }); - } + const { params, locals } = event; + const session = locals.session as Session | null; // Assuming session is directly on locals + + if (!session?.user) { + throw fail(401, { message: 'Unauthorized' }); + } + + const currentUser = session.user; + const location_id = parseInt(params.location_id || '0', 10); + + if (!location_id) { + throw fail(400, { message: 'Invalid location ID' }); + } + + const supabase = locals.supabase as SupabaseClient; + const errorHandler = new ErrorHandlingService(); + const locationRepository = new LocationRepository(supabase, errorHandler); + const deviceRepository = new DeviceRepository(supabase, errorHandler); + const locationService = new LocationService(locationRepository, deviceRepository); + const deviceTypeRepository = new DeviceTypeRepository(supabase, errorHandler); + + try { + const usersInLocation = await locationService.getLocationUsers(location_id); + const deviceTypeResults = await deviceTypeRepository.findAll(); + const deviceTypes = deviceTypeResults.map((type) => ({ + id: type.id, + name: type.name ?? '' + })); + const permissionTypes: Array<[string, PermissionLevel]> = Object.entries(PermissionLevel) + .filter(([key]) => isNaN(Number(key))) // Get string keys from enum + .map(([key, value]) => [key, value as PermissionLevel] as [string, PermissionLevel]); + + return { + currentUser, + usersInLocation: usersInLocation.filter((u) => u.user_id !== currentUser.id), + permissionTypes, + locationId: location_id, + deviceTypes + } satisfies PageData; + } catch (error) { + console.error('Error loading data for create device page:', error); + throw fail(500, { + message: + 'Failed to load page data. ' + (error instanceof Error ? error.message : 'Unknown error') + }); + } }; export const actions: Actions = { - createDevice: async (event) => { - const { request, locals, params } = event; - const session = locals.session as Session | null; // Assuming session is directly on locals - - if (!session?.user) { - return fail(401, { message: 'Unauthorized' }); - } - const currentUserId = session.user.id; - const location_id = parseInt(params.location_id || '0', 10); - - if (!location_id) { - return fail(400, { message: 'Invalid location ID' }); - } - - const formData = await request.formData(); - const name = formData.get('deviceName') as string; - const devEui = formData.get('devEui') as string; - // Ensure latitude and longitude are parsed as numbers - const latitudeStr = formData.get('latitude') as string; - const longitudeStr = formData.get('longitude') as string; - const userPermissionsData = formData.get('userPermissionsData') as string; - const deviceTypeStr = formData.get('deviceType') as string; - - const latitude = latitudeStr ? parseFloat(latitudeStr) : undefined; - const longitude = longitudeStr ? parseFloat(longitudeStr) : undefined; - const deviceType = deviceTypeStr ? parseInt(deviceTypeStr, 10) : undefined; - - if (!name || !devEui || userPermissionsData === null) { // lat/long can be optional depending on DB - return fail(400, { message: 'Missing required device information or permissions data.' }); - } - if (latitude !== undefined && isNaN(latitude)) { - return fail(400, { message: 'Invalid latitude value.'}); - } - if (longitude !== undefined && isNaN(longitude)) { - return fail(400, { message: 'Invalid longitude value.'}); - } - - let parsedUserPermissions: Array<{ userId: string; permissionLevelId: number }> = []; - try { - parsedUserPermissions = JSON.parse(userPermissionsData); - } catch (error) { - return fail(400, { message: 'Invalid format for user permissions data.' }); - } - - const supabase = locals.supabase as SupabaseClient; - const errorHandler = new ErrorHandlingService(); - const deviceRepository = new DeviceRepository(supabase, errorHandler); - const deviceService = new DeviceService(deviceRepository); - - - const deviceToInsert: DeviceInsert = { - dev_eui: devEui, - name: name, - lat: latitude, // Will be undefined if not provided, matching optional DB field - long: longitude, // Will be undefined if not provided, matching optional DB field - location_id: location_id, - user_id: currentUserId, // User who is creating the device - type: deviceType, // Add device type from form - // upload_interval are omitted, assuming they are nullable or have DB defaults - }; - - try { - const createdDevice = await deviceService.createDevice(deviceToInsert); - if (!createdDevice) { - // This case might be handled by deviceService.createDevice throwing an error - return fail(500, { message: 'Failed to create device record. The service returned no device.' }); - } - - // Add current user as Admin owner in cw_device_owners - await deviceRepository.addUserToDevice(createdDevice.dev_eui, currentUserId, PermissionLevel.Admin); - - // Add other users with specified permissions to cw_device_owners - for (const perm of parsedUserPermissions) { - if (perm.permissionLevelId !== PermissionLevel.Disabled) { // Only add if not disabled - await deviceRepository.addUserToDevice(createdDevice.dev_eui, perm.userId, perm.permissionLevelId); - } - } - - // Successfully created device and permissions - return { success: true, deviceId: createdDevice.dev_eui }; - } catch (error) { - console.error('Error creating device or setting permissions:', error); - // errorHandler.handleDatabaseError or a more specific error handler could be used - return fail(500, { message: 'Failed to create device or set permissions. ' + (error instanceof Error ? error.message : 'Unknown error') }); - } - } -}; \ No newline at end of file + createDevice: async (event) => { + const { request, locals, params } = event; + const session = locals.session as Session | null; // Assuming session is directly on locals + + if (!session?.user) { + return fail(401, { message: 'Unauthorized' }); + } + const currentUserId = session.user.id; + const location_id = parseInt(params.location_id || '0', 10); + + if (!location_id) { + return fail(400, { message: 'Invalid location ID' }); + } + + const formData = await request.formData(); + const name = formData.get('deviceName') as string; + const devEui = formData.get('devEui') as string; + // Ensure latitude and longitude are parsed as numbers + const latitudeStr = formData.get('latitude') as string; + const longitudeStr = formData.get('longitude') as string; + const userPermissionsData = formData.get('userPermissionsData') as string; + const deviceTypeStr = formData.get('deviceType') as string; + + const latitude = latitudeStr ? parseFloat(latitudeStr) : undefined; + const longitude = longitudeStr ? parseFloat(longitudeStr) : undefined; + const deviceType = deviceTypeStr ? parseInt(deviceTypeStr, 10) : undefined; + + if (!name || !devEui || userPermissionsData === null) { + // lat/long can be optional depending on DB + return fail(400, { message: 'Missing required device information or permissions data.' }); + } + if (latitude !== undefined && isNaN(latitude)) { + return fail(400, { message: 'Invalid latitude value.' }); + } + if (longitude !== undefined && isNaN(longitude)) { + return fail(400, { message: 'Invalid longitude value.' }); + } + + let parsedUserPermissions: Array<{ userId: string; permissionLevelId: number }> = []; + try { + parsedUserPermissions = JSON.parse(userPermissionsData); + } catch (error) { + return fail(400, { message: 'Invalid format for user permissions data.' }); + } + + const supabase = locals.supabase as SupabaseClient; + const errorHandler = new ErrorHandlingService(); + const deviceRepository = new DeviceRepository(supabase, errorHandler); + const deviceService = new DeviceService(deviceRepository); + + const deviceToInsert: DeviceInsert = { + dev_eui: devEui, + name: name, + lat: latitude, // Will be undefined if not provided, matching optional DB field + long: longitude, // Will be undefined if not provided, matching optional DB field + location_id: location_id, + user_id: currentUserId, // User who is creating the device + type: deviceType // Add device type from form + // upload_interval are omitted, assuming they are nullable or have DB defaults + }; + + try { + const createdDevice = await deviceService.createDevice(deviceToInsert); + if (!createdDevice) { + // This case might be handled by deviceService.createDevice throwing an error + return fail(500, { + message: 'Failed to create device record. The service returned no device.' + }); + } + + // Add current user as Admin owner in cw_device_owners + await deviceRepository.addUserToDevice( + createdDevice.dev_eui, + currentUserId, + PermissionLevel.Admin + ); + + // Add other users with specified permissions to cw_device_owners + for (const perm of parsedUserPermissions) { + if (perm.permissionLevelId !== PermissionLevel.Disabled) { + // Only add if not disabled + await deviceRepository.addUserToDevice( + createdDevice.dev_eui, + perm.userId, + perm.permissionLevelId + ); + } + } + + // Successfully created device and permissions + return { success: true, deviceId: createdDevice.dev_eui }; + } catch (error) { + console.error('Error creating device or setting permissions:', error); + // errorHandler.handleDatabaseError or a more specific error handler could be used + return fail(500, { + message: + 'Failed to create device or set permissions. ' + + (error instanceof Error ? error.message : 'Unknown error') + }); + } + } +}; diff --git a/src/routes/auth/login/+page.server.ts b/src/routes/auth/login/+page.server.ts index da87c430..131b1aa3 100644 --- a/src/routes/auth/login/+page.server.ts +++ b/src/routes/auth/login/+page.server.ts @@ -10,19 +10,35 @@ export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession export const actions: Actions = { update: async ({ request, locals: { supabase, safeGetSession } }) => { const formData = await request.formData(); - const fullName = formData.get('fullName') as string; - const username = formData.get('username') as string; - const website = formData.get('website') as string; - const avatarUrl = formData.get('avatarUrl') as string; + const fullName = formData.get('fullName'); + const username = formData.get('username'); + const website = formData.get('website'); + const avatarUrl = formData.get('avatarUrl'); const { session } = await safeGetSession(); - const { error } = await supabase.from('profiles').upsert({ - id: session?.user.id, + + if (!session?.user) { + return fail(401, { error: 'Authentication required' }); + } + + if ( + typeof fullName !== 'string' || + typeof username !== 'string' || + typeof website !== 'string' || + typeof avatarUrl !== 'string' + ) { + return fail(400, { error: 'Invalid form submission' }); + } + + const payload = { + id: session.user.id, full_name: fullName, username, website, avatar_url: avatarUrl, - updated_at: new Date() - }); + updated_at: new Date().toISOString() + }; + + const { error } = await supabase.from('profiles').upsert([payload]); if (error) { return fail(500, { fullName, diff --git a/static/build-info.json b/static/build-info.json index 8f7c4505..6ba75f00 100644 --- a/static/build-info.json +++ b/static/build-info.json @@ -1,9 +1,9 @@ { - "commit": "efbc4d6", - "branch": "develop", + "commit": "95bb936", + "branch": "polish", "author": "Kevin Cantrell", - "date": "2025-10-24T04:18:03.692Z", + "date": "2025-11-09T11:24:47.996Z", "builder": "kevin@kevin-desktop", "ipAddress": "192.168.1.100", - "timestamp": 1761279483692 + "timestamp": 1762687487997 } diff --git a/supabase/.temp/cli-latest b/supabase/.temp/cli-latest index 75788def..11335d2f 100644 --- a/supabase/.temp/cli-latest +++ b/supabase/.temp/cli-latest @@ -1 +1 @@ -v2.40.7 \ No newline at end of file +v2.54.11 \ No newline at end of file