mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
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:
@@ -12,6 +12,7 @@ import ResetPasswordPage from "./pages/ResetPasswordPage";
|
|||||||
import NotFound from "./pages/NotFound";
|
import NotFound from "./pages/NotFound";
|
||||||
import PublicFormSubmitPage from "./pages/PublicFormSubmitPage";
|
import PublicFormSubmitPage from "./pages/PublicFormSubmitPage";
|
||||||
import VendorInsuranceSubmitPage from "./pages/VendorInsuranceSubmitPage";
|
import VendorInsuranceSubmitPage from "./pages/VendorInsuranceSubmitPage";
|
||||||
|
import RvRenterInsuranceSubmitPage from "./pages/RvRenterInsuranceSubmitPage";
|
||||||
import VendorProfileSubmitPage from "./pages/VendorProfileSubmitPage";
|
import VendorProfileSubmitPage from "./pages/VendorProfileSubmitPage";
|
||||||
import TenantInfoSubmitPage from "./pages/TenantInfoSubmitPage";
|
import TenantInfoSubmitPage from "./pages/TenantInfoSubmitPage";
|
||||||
import UnsubscribePage from "./pages/UnsubscribePage";
|
import UnsubscribePage from "./pages/UnsubscribePage";
|
||||||
@@ -287,6 +288,7 @@ const App = () => (
|
|||||||
<Route path="/bill-approve/:billId" element={<BillApprovePublicPage />} />
|
<Route path="/bill-approve/:billId" element={<BillApprovePublicPage />} />
|
||||||
<Route path="/sign/:token" element={<PublicSignPage />} />
|
<Route path="/sign/:token" element={<PublicSignPage />} />
|
||||||
<Route path="/vendor-insurance/:token" element={<VendorInsuranceSubmitPage />} />
|
<Route path="/vendor-insurance/:token" element={<VendorInsuranceSubmitPage />} />
|
||||||
|
<Route path="/rv-insurance/:token" element={<RvRenterInsuranceSubmitPage />} />
|
||||||
<Route path="/vendor-profile/:token" element={<VendorProfileSubmitPage />} />
|
<Route path="/vendor-profile/:token" element={<VendorProfileSubmitPage />} />
|
||||||
<Route path="/tenant-info/:token" element={<TenantInfoSubmitPage />} />
|
<Route path="/tenant-info/:token" element={<TenantInfoSubmitPage />} />
|
||||||
<Route path="/rv-boat-waitlist/:associationId" element={<PublicRVBoatWaitlistPage />} />
|
<Route path="/rv-boat-waitlist/:associationId" element={<PublicRVBoatWaitlistPage />} />
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ type Rental = {
|
|||||||
id: string; association_id: string; lot_id: string; renter_name: string;
|
id: string; association_id: string; lot_id: string; renter_name: string;
|
||||||
renter_email: string | null; renter_phone: string | null; vehicle_description: string | null;
|
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;
|
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 = {
|
type WaitlistEntry = {
|
||||||
id: string; association_id: string; position: number;
|
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 toggleIsOwner = async (r: Rental) => {
|
||||||
const next = !r.is_owner;
|
const next = !r.is_owner;
|
||||||
const { error } = await supabase.from("rv_boat_lot_rentals")
|
const { error } = await supabase.from("rv_boat_lot_rentals")
|
||||||
@@ -641,6 +664,15 @@ export default function RVBoatLotsPage() {
|
|||||||
{r.status === "vacating" && <Badge variant="outline" className="text-[10px] border-amber-300 text-amber-700">Vacating</Badge>}
|
{r.status === "vacating" && <Badge variant="outline" className="text-[10px] border-amber-300 text-amber-700">Vacating</Badge>}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">{r.renter_email}</div>
|
<div className="text-xs text-muted-foreground">{r.renter_email}</div>
|
||||||
|
{r.insurance_carrier ? (
|
||||||
|
<div className="text-xs text-emerald-700 mt-0.5">
|
||||||
|
Insured: {r.insurance_carrier}
|
||||||
|
{r.insurance_expiration_date ? ` · exp ${new Date(r.insurance_expiration_date + "T00:00:00").toLocaleDateString()}` : ""}
|
||||||
|
{r.insurance_document_url ? <> · <a href={r.insurance_document_url} target="_blank" rel="noreferrer" className="underline">COI</a></> : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-amber-600 mt-0.5">No insurance on file</div>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm">{r.vehicle_description || "-"}</TableCell>
|
<TableCell className="text-sm">{r.vehicle_description || "-"}</TableCell>
|
||||||
<TableCell className="text-sm">{r.start_date}</TableCell>
|
<TableCell className="text-sm">{r.start_date}</TableCell>
|
||||||
@@ -651,6 +683,7 @@ export default function RVBoatLotsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="secondary" onClick={() => invitePortal(r, false)}>Invite Renter</Button>
|
<Button size="sm" variant="secondary" onClick={() => invitePortal(r, false)}>Invite Renter</Button>
|
||||||
<Button size="sm" variant="secondary" onClick={() => invitePortal(r, true)}>Invite Owner</Button>
|
<Button size="sm" variant="secondary" onClick={() => invitePortal(r, true)}>Invite Owner</Button>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => requestInsurance(r)}>Request Insurance</Button>
|
||||||
<Button size="sm" variant="outline" onClick={() => endRental(r)}>End rental</Button>
|
<Button size="sm" variant="outline" onClick={() => endRental(r)}>End rental</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -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<string | null>(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<File | null>(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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-muted/20">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submitted) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-muted/20 p-4">
|
||||||
|
<Card className="max-w-md w-full">
|
||||||
|
<CardContent className="p-8 text-center space-y-4">
|
||||||
|
<CheckCircle2 className="h-16 w-16 text-emerald-600 mx-auto" />
|
||||||
|
<h2 className="text-2xl font-bold">Thank you!</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Your insurance information has been received and your rental record has been updated.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !rental) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-muted/20 p-4">
|
||||||
|
<Card className="max-w-md w-full">
|
||||||
|
<CardContent className="p-8 text-center space-y-4">
|
||||||
|
<AlertTriangle className="h-12 w-12 text-destructive mx-auto" />
|
||||||
|
<h2 className="text-xl font-bold">Unable to submit</h2>
|
||||||
|
<p className="text-muted-foreground">{error}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-muted/20 py-10 px-4">
|
||||||
|
<div className="max-w-xl mx-auto">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ShieldCheck className="h-8 w-8 text-primary" />
|
||||||
|
<div>
|
||||||
|
<CardTitle>Submit Insurance Information</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{rental?.renter_name ? `For: ${rental.renter_name}` : "RV / Boat lot rental insurance"}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="carrier">Insurance Carrier *</Label>
|
||||||
|
<Input id="carrier" value={carrier} onChange={(e) => setCarrier(e.target.value)} placeholder="e.g. State Farm, Progressive" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="policy">Policy Number *</Label>
|
||||||
|
<Input id="policy" value={policyNumber} onChange={(e) => setPolicyNumber(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="exp">Expiration Date *</Label>
|
||||||
|
<Input id="exp" type="date" value={expirationDate} onChange={(e) => setExpirationDate(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="doc">Certificate of Insurance (optional)</Label>
|
||||||
|
<Input id="doc" type="file" accept=".pdf,.png,.jpg,.jpeg" onChange={(e) => setDocFile(e.target.files?.[0] || null)} />
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">PDF or image, up to 10MB.</p>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
<Button type="submit" className="w-full" disabled={submitting}>
|
||||||
|
{submitting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||||
|
Submit Insurance Information
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user