RV/Boat Lots: request renter insurance (vendor-style flow)

Phase 4. Mirror the vendor insurance request flow for RV/boat renters:
- Migration: insurance fields on rv_boat_lot_rentals + rv_renter_insurance_requests
  table + token-scoped lookup/submit SECURITY DEFINER RPCs (granted to anon).
- Edge fn send-rv-renter-insurance-request emails the renter a secure link
  (reuses the vendor-insurance-request email template).
- Public page /rv-insurance/:token to submit carrier/policy/expiration + COI upload.
- "Request Insurance" button on each active rental + insurance status display.

DB RPCs verified end-to-end (rolled-back txn): submit matches token, updates the
rental, marks the request submitted. Edge function deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 20:16:31 -04:00
parent d8465f2297
commit 308af20aa1
5 changed files with 421 additions and 1 deletions
@@ -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' },
})
}
})
@@ -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;