0ac4beaaa8
- Add requireAuth() to admin-permissions.ts as recognized auth call - Convert getAdminUser() → requireAuth() across 73 admin action files - Add getSession() to public/wholesale server actions - Fix multi-line return type corruption from earlier auto-fixers - Move FedEx token cache to non-'use server' module - Object.freeze module-level constants: PRICE_KEYS, EMPTY_MOBILE_DASHBOARD, EMPTY_PAY_PERIOD, LOCALE_CART_SUBJECT, WELCOME_EMAILS - Update Stripe API version 2026-05-27 → 2026-06-24 - Fix wholesale employee portal: getEmployeeSessionAction + EmployeePortalClient - Fix 51 TypeScript errors (return type corruption, missing imports)
278 lines
10 KiB
PL/PgSQL
278 lines
10 KiB
PL/PgSQL
-- Migration 120: Water Log High/Low Alerts
|
|
-- 1. Add threshold columns to water_headgates
|
|
-- 2. Add alert config to water_admin_settings
|
|
-- 3. Create water_alert_log table
|
|
-- 4. Create trigger_water_alert RPC with Twilio SMS
|
|
|
|
-- 1. Threshold columns
|
|
ALTER TABLE water_headgates ADD COLUMN IF NOT EXISTS high_threshold NUMERIC(12,4);
|
|
ALTER TABLE water_headgates ADD COLUMN IF NOT EXISTS low_threshold NUMERIC(12,4);
|
|
|
|
-- 2. Alert config columns
|
|
ALTER TABLE water_admin_settings ADD COLUMN IF NOT EXISTS alert_phone TEXT;
|
|
ALTER TABLE water_admin_settings ADD COLUMN IF NOT EXISTS alerts_enabled BOOLEAN NOT NULL DEFAULT false;
|
|
|
|
-- 3. Alert log table
|
|
CREATE TABLE IF NOT EXISTS water_alert_log (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
|
headgate_id UUID NOT NULL REFERENCES water_headgates(id) ON DELETE CASCADE,
|
|
entry_id UUID NOT NULL REFERENCES water_log_entries(id) ON DELETE CASCADE,
|
|
alert_type TEXT NOT NULL CHECK (alert_type IN ('high', 'low')),
|
|
threshold_value NUMERIC(12,4) NOT NULL,
|
|
reading_value NUMERIC(12,4) NOT NULL,
|
|
message_sent TEXT,
|
|
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
-- 4. Update save_water_admin_settings to handle new columns
|
|
CREATE OR REPLACE FUNCTION save_water_admin_settings(
|
|
p_brand_id UUID,
|
|
p_enabled BOOLEAN,
|
|
p_pin_hash TEXT,
|
|
p_session_duration_hours INTEGER,
|
|
p_can_edit_entries BOOLEAN,
|
|
p_can_delete_entries BOOLEAN,
|
|
p_can_export_csv BOOLEAN,
|
|
p_alert_phone TEXT DEFAULT NULL,
|
|
p_alerts_enabled BOOLEAN DEFAULT NULL
|
|
)
|
|
RETURNS JSONB
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
AS $$
|
|
BEGIN
|
|
INSERT INTO water_admin_settings
|
|
(brand_id, enabled, pin_hash, session_duration_hours, can_edit_entries, can_delete_entries, can_export_csv, alert_phone, alerts_enabled)
|
|
VALUES
|
|
(p_brand_id, p_enabled, p_pin_hash, p_session_duration_hours, p_can_edit_entries, p_can_delete_entries, p_can_export_csv, p_alert_phone, COALESCE(p_alerts_enabled, false))
|
|
ON CONFLICT (brand_id) DO UPDATE SET
|
|
enabled = EXCLUDED.enabled,
|
|
pin_hash = COALESCE(EXCLUDED.pin_hash, water_admin_settings.pin_hash),
|
|
session_duration_hours = EXCLUDED.session_duration_hours,
|
|
can_edit_entries = EXCLUDED.can_edit_entries,
|
|
can_delete_entries = EXCLUDED.can_delete_entries,
|
|
can_export_csv = EXCLUDED.can_export_csv,
|
|
alert_phone = COALESCE(EXCLUDED.alert_phone, water_admin_settings.alert_phone),
|
|
alerts_enabled = COALESCE(EXCLUDED.alerts_enabled, water_admin_settings.alerts_enabled),
|
|
updated_at = now();
|
|
|
|
RETURN jsonb_build_object('success', true);
|
|
END;
|
|
$$;
|
|
|
|
-- 5. Update get_water_admin_settings to return new columns
|
|
CREATE OR REPLACE FUNCTION get_water_admin_settings(p_brand_id UUID)
|
|
RETURNS JSONB
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
AS $$
|
|
DECLARE
|
|
v_result JSONB;
|
|
BEGIN
|
|
SELECT jsonb_build_object(
|
|
'enabled', enabled,
|
|
'session_duration_hours', session_duration_hours,
|
|
'can_edit_entries', can_edit_entries,
|
|
'can_delete_entries', can_delete_entries,
|
|
'can_export_csv', can_export_csv,
|
|
'alert_phone', alert_phone,
|
|
'alerts_enabled', COALESCE(alerts_enabled, false)
|
|
) INTO v_result
|
|
FROM water_admin_settings
|
|
WHERE brand_id = p_brand_id;
|
|
|
|
RETURN v_result;
|
|
END;
|
|
$$;
|
|
|
|
-- 6. Update water_headgates admin view/RPC to include thresholds
|
|
CREATE OR REPLACE FUNCTION get_water_headgates_admin(p_brand_id UUID)
|
|
RETURNS JSONB
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
AS $$
|
|
DECLARE
|
|
v_result JSONB;
|
|
BEGIN
|
|
SELECT jsonb_agg(jsonb_build_object(
|
|
'id', id,
|
|
'name', name,
|
|
'active', active,
|
|
'unit', unit,
|
|
'created_at', created_at,
|
|
'headgate_token', headgate_token,
|
|
'high_threshold', high_threshold,
|
|
'low_threshold', low_threshold,
|
|
'last_used_at', last_used_at
|
|
) ORDER BY created_at DESC) INTO v_result
|
|
FROM water_headgates
|
|
WHERE brand_id = p_brand_id AND deleted_at IS NULL;
|
|
|
|
RETURN jsonb_build_object('headgates', COALESCE(v_result, '[]'::jsonb));
|
|
END;
|
|
$$;
|
|
|
|
-- 7. Trigger alert RPC — checks thresholds and sends Twilio SMS
|
|
CREATE OR REPLACE FUNCTION trigger_water_alert(
|
|
p_entry_id UUID,
|
|
p_alert_type TEXT,
|
|
p_threshold_value NUMERIC,
|
|
p_reading_value NUMERIC
|
|
)
|
|
RETURNS JSONB
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
AS $$
|
|
DECLARE
|
|
v_headgate_id UUID;
|
|
v_headgate_name TEXT;
|
|
v_headgate_high NUMERIC;
|
|
v_headgate_low NUMERIC;
|
|
v_brand_id UUID;
|
|
v_alert_phone TEXT;
|
|
v_alerts_enabled BOOLEAN;
|
|
v_site_url TEXT;
|
|
v_message TEXT;
|
|
v_sent BOOLEAN := false;
|
|
v_result JSONB;
|
|
v_entry_time TEXT;
|
|
v_entry_headgate_id UUID;
|
|
v_entry_brand_id UUID;
|
|
v_entry_measurement NUMERIC;
|
|
v_entry_unit TEXT;
|
|
BEGIN
|
|
-- Look up the entry to get headgate + brand info
|
|
SELECT headgate_id, brand_id, measurement, unit, TO_CHAR(logged_at, 'YYYY-MM-DD HH24:MI') INTO v_entry_headgate_id, v_entry_brand_id, v_entry_measurement, v_entry_unit, v_entry_time
|
|
FROM water_log_entries
|
|
WHERE id = p_entry_id;
|
|
|
|
IF NOT FOUND THEN
|
|
RETURN jsonb_build_object('success', false, 'error', 'Entry not found');
|
|
END IF;
|
|
|
|
-- Look up headgate thresholds
|
|
SELECT id, name, high_threshold, low_threshold INTO v_headgate_id, v_headgate_name, v_headgate_high, v_headgate_low
|
|
FROM water_headgates
|
|
WHERE id = v_entry_headgate_id AND deleted_at IS NULL;
|
|
|
|
IF NOT FOUND THEN
|
|
RETURN jsonb_build_object('success', false, 'error', 'Headgate not found');
|
|
END IF;
|
|
|
|
-- Look up admin settings for this brand
|
|
SELECT alert_phone, COALESCE(alerts_enabled, false) INTO v_alert_phone, v_alerts_enabled
|
|
FROM water_admin_settings
|
|
WHERE brand_id = v_entry_brand_id;
|
|
|
|
-- Check if alert should fire
|
|
IF NOT v_alerts_enabled OR v_alert_phone IS NULL OR v_alert_phone = '' THEN
|
|
RETURN jsonb_build_object('success', true, 'triggered', false, 'reason', 'alerts disabled or no phone');
|
|
END IF;
|
|
|
|
-- Check threshold breach
|
|
IF p_alert_type = 'high' AND (v_headgate_high IS NULL OR p_reading_value <= v_headgate_high) THEN
|
|
RETURN jsonb_build_object('success', true, 'triggered', false, 'reason', 'not above high threshold');
|
|
END IF;
|
|
|
|
IF p_alert_type = 'low' AND (v_headgate_low IS NULL OR p_reading_value >= v_headgate_low) THEN
|
|
RETURN jsonb_build_object('success', true, 'triggered', false, 'reason', 'not below low threshold');
|
|
END IF;
|
|
|
|
-- Build message
|
|
v_site_url := COALESCE(NULLIF(COALESCE(current_setting('app.settings.site_url', true), ''), ''), 'https://routecommerce.com');
|
|
|
|
IF p_alert_type = 'high' THEN
|
|
v_message := 'Alert: Headgate ''' || v_headgate_name || ''' reading ' || p_reading_value || ' ' || v_entry_unit || ' exceeded High threshold of ' || v_headgate_high || ' at ' || v_entry_time || '. View: ' || v_site_url || '/water/admin';
|
|
ELSE
|
|
v_message := 'Alert: Headgate ''' || v_headgate_name || ''' reading ' || p_reading_value || ' ' || v_entry_unit || ' fell below Low threshold of ' || v_headgate_low || ' at ' || v_entry_time || '. View: ' || v_site_url || '/water/admin';
|
|
END IF;
|
|
|
|
-- Insert alert log
|
|
INSERT INTO water_alert_log (brand_id, headgate_id, entry_id, alert_type, threshold_value, reading_value, message_sent, sent_at)
|
|
VALUES (v_entry_brand_id, v_headgate_id, p_entry_id, p_alert_type, p_threshold_value, p_reading_value, v_message, now())
|
|
RETURNING id INTO v_result;
|
|
|
|
-- Send SMS via Twilio REST API (ignore failures — alert is logged either way)
|
|
PERFORM (
|
|
SELECT net.http_post(
|
|
url := 'https://api.twilio.com/2010-04-01/Accounts/' || NULLIF(COALESCE(current_setting('app.settings.twilio_account_sid', true), ''), '') || '/Messages.json',
|
|
headers := '{"Authorization": "Basic " || encode((' || Quote_literal(COALESCE(NULLIF(current_setting('app.settings.twilio_account_sid', true), ''), '') || ':' || COALESCE(NULLIF(current_setting('app.settings.twilio_auth_token', true), ''), '')) || ')::bytea, ''base64''), "Content-Type": "application/x-www-form-urlencoded"}',
|
|
body := 'From=' || COALESCE(NULLIF(COALESCE(current_setting('app.settings.twilio_phone_number', true), ''), ''), '') || '&To=' || v_alert_phone || '&Body=' || urlencode(v_message)
|
|
)
|
|
WHERE NULLIF(COALESCE(current_setting('app.settings.twilio_account_sid', true), ''), '') IS NOT NULL
|
|
);
|
|
|
|
RETURN jsonb_build_object('success', true, 'triggered', true, 'message', v_message, 'alert_log_id', v_result);
|
|
EXCEPTION
|
|
WHEN OTHERS THEN
|
|
-- Log but don't fail — alert is recorded
|
|
RETURN jsonb_build_object('success', true, 'triggered', true, 'message', v_message, 'sms_error', SQLERRM);
|
|
END;
|
|
$$;
|
|
|
|
-- 8. Update update_water_headgate to accept thresholds
|
|
CREATE OR REPLACE FUNCTION update_water_headgate(
|
|
p_headgate_id UUID,
|
|
p_name TEXT,
|
|
p_active BOOLEAN,
|
|
p_unit TEXT,
|
|
p_brand_id UUID,
|
|
p_high_threshold NUMERIC DEFAULT NULL,
|
|
p_low_threshold NUMERIC DEFAULT NULL
|
|
)
|
|
RETURNS JSONB
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
AS $$
|
|
BEGIN
|
|
UPDATE water_headgates
|
|
SET name = p_name, active = p_active, unit = p_unit,
|
|
high_threshold = p_high_threshold, low_threshold = p_low_threshold
|
|
WHERE id = p_headgate_id AND brand_id = p_brand_id AND deleted_at IS NULL;
|
|
|
|
IF NOT FOUND THEN
|
|
RETURN jsonb_build_object('success', false, 'error', 'Headgate not found');
|
|
END IF;
|
|
|
|
RETURN jsonb_build_object('success', true);
|
|
END;
|
|
$$;
|
|
|
|
-- 9. Create alert log RPC for fetching
|
|
CREATE OR REPLACE FUNCTION get_water_alert_log(p_brand_id UUID, p_limit INTEGER DEFAULT 50)
|
|
RETURNS JSONB
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
AS $$
|
|
DECLARE
|
|
v_result JSONB;
|
|
BEGIN
|
|
SELECT jsonb_agg(q ORDER BY created_at DESC)
|
|
INTO v_result
|
|
FROM (
|
|
SELECT
|
|
a.id, a.alert_type, a.threshold_value, a.reading_value,
|
|
a.message_sent, a.sent_at, a.created_at,
|
|
h.name AS headgate_name,
|
|
TO_CHAR(a.created_at, 'YYYY-MM-DD HH24:MI') AS formatted_time
|
|
FROM water_alert_log a
|
|
JOIN water_headgates h ON h.id = a.headgate_id
|
|
WHERE a.brand_id = p_brand_id
|
|
ORDER BY a.created_at DESC
|
|
LIMIT p_limit
|
|
) q;
|
|
|
|
RETURN jsonb_build_object('alerts', COALESCE(v_result, '[]'::jsonb));
|
|
END;
|
|
$$;
|
|
-- ============================================================
|
|
-- RLS for water_alert_log (added by scripts/fix-archived-rls.js)
|
|
-- Columns: id, brand_id, headgate_id, entry_id, alert_type, threshold_value, reading_value, message_sent, sent_at, created_at
|
|
-- ============================================================
|
|
ALTER TABLE water_alert_log ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE water_alert_log FORCE ROW LEVEL SECURITY;
|
|
DROP POLICY IF EXISTS tenant_isolation ON water_alert_log;
|
|
CREATE POLICY tenant_isolation ON water_alert_log FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|