diff --git a/src/App.tsx b/src/App.tsx index 8658243..cb06e9c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import ResetPasswordPage from "./pages/ResetPasswordPage"; import NotFound from "./pages/NotFound"; import PublicFormSubmitPage from "./pages/PublicFormSubmitPage"; import VendorInsuranceSubmitPage from "./pages/VendorInsuranceSubmitPage"; +import RvRenterInsuranceSubmitPage from "./pages/RvRenterInsuranceSubmitPage"; import VendorProfileSubmitPage from "./pages/VendorProfileSubmitPage"; import TenantInfoSubmitPage from "./pages/TenantInfoSubmitPage"; import UnsubscribePage from "./pages/UnsubscribePage"; @@ -287,6 +288,7 @@ const App = () => ( } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/RVBoatLotsPage.tsx b/src/pages/RVBoatLotsPage.tsx index 7ccffd1..ae5e14a 100644 --- a/src/pages/RVBoatLotsPage.tsx +++ b/src/pages/RVBoatLotsPage.tsx @@ -24,7 +24,9 @@ type Rental = { id: string; association_id: string; lot_id: string; renter_name: string; renter_email: string | null; renter_phone: string | null; vehicle_description: string | null; start_date: string; end_date: string | null; monthly_rate: number | null; status: string; notes: string | null; - is_owner?: boolean | null; user_id?: string | null; + is_owner?: boolean | null; user_id?: string | null; owner_id?: string | null; unit_id?: string | null; + insurance_carrier?: string | null; insurance_policy_number?: string | null; + insurance_expiration_date?: string | null; insurance_document_url?: string | null; }; type WaitlistEntry = { id: string; association_id: string; position: number; @@ -251,6 +253,27 @@ export default function RVBoatLotsPage() { } }; + const requestInsurance = async (r: Rental) => { + if (!r.renter_email) { toast.error("Add a renter email first, then request insurance."); return; } + try { + const { data, error } = await supabase.functions.invoke("send-rv-renter-insurance-request", { + body: { rental_id: r.id }, + }); + if (error) throw error; + const res = data as any; + if (res?.error && !res?.link) throw new Error(res.error); + if (res?.ok) { + toast.success(`Insurance request emailed to ${res.sent_to}`); + } else if (res?.link) { + await navigator.clipboard.writeText(res.link).catch(() => {}); + toast.message("Email didn't send — link copied", { description: "Share the submission link with the renter manually." }); + } + load(); + } catch (e: any) { + toast.error(e.message || "Failed to request insurance"); + } + }; + const toggleIsOwner = async (r: Rental) => { const next = !r.is_owner; const { error } = await supabase.from("rv_boat_lot_rentals") @@ -641,6 +664,15 @@ export default function RVBoatLotsPage() { {r.status === "vacating" && Vacating}
{r.renter_email}
+ {r.insurance_carrier ? ( +
+ Insured: {r.insurance_carrier} + {r.insurance_expiration_date ? ` · exp ${new Date(r.insurance_expiration_date + "T00:00:00").toLocaleDateString()}` : ""} + {r.insurance_document_url ? <> · COI : null} +
+ ) : ( +
No insurance on file
+ )} {r.vehicle_description || "-"} {r.start_date} @@ -651,6 +683,7 @@ export default function RVBoatLotsPage() { + diff --git a/src/pages/RvRenterInsuranceSubmitPage.tsx b/src/pages/RvRenterInsuranceSubmitPage.tsx new file mode 100644 index 0000000..3265339 --- /dev/null +++ b/src/pages/RvRenterInsuranceSubmitPage.tsx @@ -0,0 +1,170 @@ +import { useState, useEffect } from "react"; +import { useParams } from "react-router-dom"; +import { supabase } from "@/integrations/supabase/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { CheckCircle2, Loader2, ShieldCheck, AlertTriangle } from "lucide-react"; + +export default function RvRenterInsuranceSubmitPage() { + const { token } = useParams<{ token: string }>(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [submitted, setSubmitted] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [rental, setRental] = useState<{ rental_id: string; renter_name: string } | null>(null); + + const [carrier, setCarrier] = useState(""); + const [policyNumber, setPolicyNumber] = useState(""); + const [expirationDate, setExpirationDate] = useState(""); + const [docFile, setDocFile] = useState(null); + + useEffect(() => { + if (!token) return; + (async () => { + setLoading(true); + const { data, error } = await supabase.rpc("lookup_rv_renter_insurance_request", { p_token: token }); + if (error || !data || data.length === 0) { + setError("This link is invalid or could not be found."); + setLoading(false); + return; + } + const row = data[0]; + if (row.submitted_at) { + setError("This insurance information has already been submitted. If you need to update it, please contact us."); + } else if (new Date(row.expires_at) < new Date()) { + setError("This link has expired. Please contact us to request a new submission link."); + } else { + setRental({ rental_id: row.rental_id, renter_name: row.renter_name }); + } + setLoading(false); + })(); + }, [token]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!token || !rental) return; + if (!carrier.trim() || !policyNumber.trim() || !expirationDate) { + setError("Please complete all required fields."); + return; + } + setSubmitting(true); + setError(null); + + let documentUrl: string | null = null; + if (docFile) { + const ext = docFile.name.split(".").pop() || "pdf"; + const path = `rv-renter-insurance/${rental.rental_id}/${Date.now()}.${ext}`; + const { error: upErr } = await supabase.storage + .from("public-form-attachments") + .upload(path, docFile, { upsert: false }); + if (upErr) { + setError("Could not upload document: " + upErr.message); + setSubmitting(false); + return; + } + const { data: pub } = supabase.storage.from("public-form-attachments").getPublicUrl(path); + documentUrl = pub.publicUrl; + } + + const { data, error: rpcErr } = await supabase.rpc("submit_rv_renter_insurance", { + p_token: token, + p_carrier: carrier.trim(), + p_policy_number: policyNumber.trim(), + p_expiration_date: expirationDate, + p_document_url: documentUrl, + }); + + setSubmitting(false); + if (rpcErr || data === false) { + setError("Submission failed. The link may have expired. Please contact us."); + return; + } + setSubmitted(true); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (submitted) { + return ( +
+ + + +

Thank you!

+

+ Your insurance information has been received and your rental record has been updated. +

+
+
+
+ ); + } + + if (error && !rental) { + return ( +
+ + + +

Unable to submit

+

{error}

+
+
+
+ ); + } + + return ( +
+
+ + +
+ +
+ Submit Insurance Information + + {rental?.renter_name ? `For: ${rental.renter_name}` : "RV / Boat lot rental insurance"} + +
+
+
+ +
+
+ + setCarrier(e.target.value)} placeholder="e.g. State Farm, Progressive" required /> +
+
+ + setPolicyNumber(e.target.value)} required /> +
+
+ + setExpirationDate(e.target.value)} required /> +
+
+ + setDocFile(e.target.files?.[0] || null)} /> +

PDF or image, up to 10MB.

+
+ {error &&

{error}

} + +
+
+
+
+
+ ); +} diff --git a/supabase/functions/send-rv-renter-insurance-request/index.ts b/supabase/functions/send-rv-renter-insurance-request/index.ts new file mode 100644 index 0000000..c3f0d7f --- /dev/null +++ b/supabase/functions/send-rv-renter-insurance-request/index.ts @@ -0,0 +1,134 @@ +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.45.0' + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +} + +const PUBLIC_BASE_URL = 'https://avria.cloud' + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders }) + + try { + const { rental_id } = await req.json() + if (!rental_id) { + return new Response(JSON.stringify({ error: 'rental_id required' }), { + status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + } + + const supabaseUrl = Deno.env.get('SUPABASE_URL')! + const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! + const anonKey = Deno.env.get('SUPABASE_ANON_KEY')! + const authHeader = req.headers.get('Authorization') || '' + + const authClient = createClient(supabaseUrl, anonKey, { + global: { headers: { Authorization: authHeader } }, + }) + const { data: userRes } = await authClient.auth.getUser() + if (!userRes?.user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + } + const userId = userRes.user.id + + const admin = createClient(supabaseUrl, serviceKey) + + const { data: roleRows } = await admin + .from('user_roles').select('role').eq('user_id', userId) + const roles = (roleRows || []).map((r: any) => r.role) + if (!roles.includes('admin') && !roles.includes('manager') && !roles.includes('association_management')) { + return new Response(JSON.stringify({ error: 'Forbidden' }), { + status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + } + + const { data: rental, error: rErr } = await admin + .from('rv_boat_lot_rentals') + .select('id, renter_name, renter_email') + .eq('id', rental_id) + .single() + if (rErr || !rental) { + return new Response(JSON.stringify({ error: 'Rental not found' }), { + status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + } + if (!rental.renter_email) { + return new Response(JSON.stringify({ error: 'Renter has no email on file' }), { + status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + } + + const { data: profile } = await admin + .from('profiles').select('full_name').eq('user_id', userId).maybeSingle() + + const { data: reqRow, error: reqErr } = await admin + .from('rv_renter_insurance_requests') + .insert({ rental_id, sent_to_email: rental.renter_email, created_by: userId }) + .select('id, token, expires_at') + .single() + if (reqErr || !reqRow) { + return new Response(JSON.stringify({ error: 'Failed to create request', detail: reqErr?.message }), { + status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + } + + const link = `${PUBLIC_BASE_URL}/rv-insurance/${reqRow.token}` + const expiresAt = new Date(reqRow.expires_at).toLocaleDateString('en-US', { + month: 'long', day: 'numeric', year: 'numeric', + }) + + let emailErr: any = null + try { + const emailRes = await fetch(`${supabaseUrl}/functions/v1/send-transactional-email`, { + method: 'POST', + headers: { + 'apikey': anonKey, + ...(authHeader ? { Authorization: authHeader } : {}), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + templateName: 'vendor-insurance-request', + recipientEmail: rental.renter_email, + idempotencyKey: `rv-renter-insurance-${reqRow.id}`, + templateData: { + vendorName: rental.renter_name, + requesterName: profile?.full_name || 'Avria Community Management', + link, + expiresAt, + }, + }), + }) + if (!emailRes.ok) { + const text = await emailRes.text() + emailErr = { message: `status ${emailRes.status}: ${text}` } + } + } catch (e) { + emailErr = e + } + + if (emailErr) { + console.error('Email send failed:', emailErr) + return new Response(JSON.stringify({ + ok: false, + request_id: reqRow.id, + link, + error: 'Email failed to send. Share the link manually.', + }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }) + } + + return new Response(JSON.stringify({ + ok: true, + request_id: reqRow.id, + link, + sent_to: rental.renter_email, + }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' } }) + } catch (e) { + console.error('send-rv-renter-insurance-request error:', e) + return new Response(JSON.stringify({ error: String(e) }), { + status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + } +}) diff --git a/supabase/migrations/20260607230000_rv_renter_insurance_requests.sql b/supabase/migrations/20260607230000_rv_renter_insurance_requests.sql new file mode 100644 index 0000000..b2e210b --- /dev/null +++ b/supabase/migrations/20260607230000_rv_renter_insurance_requests.sql @@ -0,0 +1,81 @@ +-- RV/Boat renter insurance: mirror of the vendor insurance request flow. +-- Adds insurance fields to rentals, a token-based request table, and the +-- public lookup/submit RPCs used by the renter-facing submission page. + +alter table public.rv_boat_lot_rentals + add column if not exists insurance_carrier text, + add column if not exists insurance_policy_number text, + add column if not exists insurance_expiration_date date, + add column if not exists insurance_document_url text; + +create table if not exists public.rv_renter_insurance_requests ( + id uuid primary key default gen_random_uuid(), + rental_id uuid not null references public.rv_boat_lot_rentals(id) on delete cascade, + token text not null default encode(gen_random_bytes(24), 'hex'), + sent_to_email text, + sent_at timestamptz default now(), + submitted_at timestamptz, + expires_at timestamptz default (now() + interval '30 days'), + created_by uuid, + created_at timestamptz default now() +); + +create index if not exists idx_rv_renter_ins_req_token on public.rv_renter_insurance_requests(token); +create index if not exists idx_rv_renter_ins_req_rental on public.rv_renter_insurance_requests(rental_id); + +alter table public.rv_renter_insurance_requests enable row level security; + +drop policy if exists "Staff manage rv renter insurance requests" on public.rv_renter_insurance_requests; +create policy "Staff manage rv renter insurance requests" + on public.rv_renter_insurance_requests for all + to authenticated + using (has_role(auth.uid(), 'admin'::app_role) or has_role(auth.uid(), 'manager'::app_role) or has_role(auth.uid(), 'association_management'::app_role)) + with check (has_role(auth.uid(), 'admin'::app_role) or has_role(auth.uid(), 'manager'::app_role) or has_role(auth.uid(), 'association_management'::app_role)); + +-- Public, token-scoped lookup (no direct table access needed by the renter). +create or replace function public.lookup_rv_renter_insurance_request(p_token text) +returns table(request_id uuid, rental_id uuid, renter_name text, expires_at timestamptz, submitted_at timestamptz) +language sql stable security definer set search_path to 'public' +as $$ + select r.id, rn.id, rn.renter_name, r.expires_at, r.submitted_at + from public.rv_renter_insurance_requests r + join public.rv_boat_lot_rentals rn on rn.id = r.rental_id + where r.token = p_token + limit 1; +$$; + +create or replace function public.submit_rv_renter_insurance( + p_token text, + p_carrier text, + p_policy_number text, + p_expiration_date date, + p_document_url text default null +) returns boolean +language plpgsql security definer set search_path to 'public' +as $$ +declare + v_request record; +begin + select * into v_request from public.rv_renter_insurance_requests + where token = p_token and expires_at > now() and submitted_at is null + limit 1; + + if v_request is null then + return false; + end if; + + update public.rv_boat_lot_rentals + set insurance_carrier = p_carrier, + insurance_policy_number = p_policy_number, + insurance_expiration_date = p_expiration_date, + insurance_document_url = coalesce(p_document_url, insurance_document_url), + updated_at = now() + where id = v_request.rental_id; + + update public.rv_renter_insurance_requests set submitted_at = now() where id = v_request.id; + return true; +end; +$$; + +grant execute on function public.lookup_rv_renter_insurance_request(text) to anon, authenticated; +grant execute on function public.submit_rv_renter_insurance(text, text, text, date, text) to anon, authenticated;