mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
Hostinger Reach integration UI + ARC Buildium matching, drop Mailchimp
- HostingerReachPage (replaces MailchimpPage): connect Reach via reach-connection, per-association segment sync via reach-sync - ARC Applications: Buildium import review/matching updates - buildium-import-stage/apply: latest staging + apply changes (already deployed to Supabase) - migrations: hostinger_reach_integration + arc_finalized_lock service role (already applied to live DB) - CI: note that deployment is VPS-side polling (auto-deploy.sh cron) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1 @@
|
||||
v2.105.0
|
||||
v2.106.0
|
||||
@@ -260,10 +260,7 @@ Deno.serve(async (req) => {
|
||||
const files: Array<{ id: string; name: string }> = Array.isArray(p._arc_files) ? p._arc_files : [];
|
||||
const buildiumAssocId: string | null = p._arc_buildium_association_id || null;
|
||||
const buildiumArcId: string | null = p.buildium_arc_request_id || null;
|
||||
const decisionNotes: string | null = p.decision_notes || null;
|
||||
const reviewDate: string | null = p.review_date || null;
|
||||
const deciderName: string | null = p._arc_decider_name || null;
|
||||
const deciderDate: string | null = p._arc_decider_date || null;
|
||||
|
||||
const clean = stripPrivate(p);
|
||||
|
||||
@@ -282,27 +279,32 @@ Deno.serve(async (req) => {
|
||||
appId = ins.id;
|
||||
}
|
||||
|
||||
// Seed a system comment with the Buildium decision (since comments/voters aren't exposed via API)
|
||||
if (appId && (decisionNotes || deciderName)) {
|
||||
const { data: existingComment } = await supabase
|
||||
.from("arc_application_comments")
|
||||
.select("id")
|
||||
.eq("application_id", appId)
|
||||
// Buildium's API exposes no comment threads or per-member votes — only the final decision
|
||||
// and who recorded it. Surface that decision as a recorded vote in the Committee Review
|
||||
// (entity_votes is what the ARC review UI reads). The decision text itself already lives in
|
||||
// arc_applications.decision_notes. Idempotent: re-syncing replaces the prior Buildium vote.
|
||||
const decisionRaw: string = String(p._arc_decision || "").toLowerCase();
|
||||
const voteDir: "approve" | "deny" | null = decisionRaw.includes("approve")
|
||||
? "approve"
|
||||
: (decisionRaw.includes("den") || decisionRaw.includes("reject") ? "deny" : null);
|
||||
if (appId && voteDir) {
|
||||
const voterName = `${deciderName || "Buildium"} (Buildium)`;
|
||||
await supabase
|
||||
.from("entity_votes")
|
||||
.delete()
|
||||
.eq("entity_type", "arc_application")
|
||||
.eq("entity_id", appId)
|
||||
.is("user_id", null)
|
||||
.ilike("comment", "%[Imported from Buildium]%")
|
||||
.maybeSingle();
|
||||
if (!existingComment) {
|
||||
const seed =
|
||||
`[Imported from Buildium]\n` +
|
||||
(deciderName ? `Decision by: ${deciderName}${deciderDate ? ` on ${deciderDate}` : ""}\n` : "") +
|
||||
(reviewDate && !deciderDate ? `Decision date: ${reviewDate}\n` : "") +
|
||||
(decisionNotes ? `Decision notes: ${decisionNotes}` : "");
|
||||
await supabase.from("arc_application_comments").insert({
|
||||
application_id: appId,
|
||||
user_id: null,
|
||||
comment: seed.trim(),
|
||||
});
|
||||
}
|
||||
.ilike("voter_name", "% (Buildium)");
|
||||
const { error: voteErr } = await supabase.from("entity_votes").insert({
|
||||
entity_type: "arc_application",
|
||||
entity_id: appId,
|
||||
vote: voteDir,
|
||||
user_id: null,
|
||||
voter_name: voterName,
|
||||
recorded_by: null,
|
||||
});
|
||||
if (voteErr) console.warn(`ARC vote record failed for ${appId}: ${voteErr.message}`);
|
||||
}
|
||||
|
||||
// Download attached files from Buildium and upload into the arc-files bucket
|
||||
|
||||
@@ -443,6 +443,21 @@ Deno.serve(async (req) => {
|
||||
const ownerByBuildiumLocal = new Map<string, any>();
|
||||
for (const o of ownersAll || []) ownerByBuildiumLocal.set(String(o.buildium_owner_id), o);
|
||||
|
||||
// Bridge: a Buildium ARC request only exposes OwnershipAccountId, but local owners are
|
||||
// keyed by the *association owner* id. Map ownership account -> { unit, owners } so we can
|
||||
// resolve the request's unit (property address) and owner.
|
||||
const arcOwnershipAccounts = await buildiumFetchAll("/v1/associations/ownershipaccounts", clientId, clientSecret);
|
||||
const ownershipAccountById = new Map<string, { unitBuildiumId: string | null; ownerBuildiumIds: string[] }>();
|
||||
for (const acct of arcOwnershipAccounts) {
|
||||
const ownerIds: string[] = [];
|
||||
if (Array.isArray(acct.AssociationOwnerIds)) for (const id of acct.AssociationOwnerIds) ownerIds.push(String(id));
|
||||
if (acct.AssociationOwnerId) ownerIds.push(String(acct.AssociationOwnerId));
|
||||
ownershipAccountById.set(String(acct.Id), {
|
||||
unitBuildiumId: normId(acct.UnitId),
|
||||
ownerBuildiumIds: [...new Set(ownerIds)],
|
||||
});
|
||||
}
|
||||
|
||||
for (const ba of buildiumAssocs) {
|
||||
const assocLocalId = bAssocIdToLocalId.get(String(ba.Id));
|
||||
if (!assocLocalId || !isSelected(assocLocalId)) continue;
|
||||
@@ -462,10 +477,30 @@ Deno.serve(async (req) => {
|
||||
|
||||
for (const r of arcRequests) {
|
||||
const buildiumArcId = String(r.Id);
|
||||
// Buildium ARC payload doesn't expose UnitId — resolve unit via the owner's unit later.
|
||||
const buildiumUnitId = normId(r.UnitId);
|
||||
const buildiumOwnerId = normId(r.OwnershipAccountId || r.AssociationOwnerId);
|
||||
const localOwner = buildiumOwnerId ? ownerByBuildiumLocal.get(buildiumOwnerId) : null;
|
||||
// Buildium ARC requests expose only OwnershipAccountId. Bridge it to the unit + owner
|
||||
// via the ownership-accounts map built above.
|
||||
const ownershipAccountId = normId(r.OwnershipAccountId);
|
||||
const acctInfo = ownershipAccountId ? ownershipAccountById.get(ownershipAccountId) : null;
|
||||
|
||||
// Resolve unit: prefer the ARC's own UnitId if present, else the ownership account's unit.
|
||||
const buildiumUnitId = normId(r.UnitId) || acctInfo?.unitBuildiumId || null;
|
||||
|
||||
// Resolve owner: walk the ownership account's association-owner ids (the id space local
|
||||
// owners are keyed by) and take the first that maps to a local owner.
|
||||
let resolvedOwnerBuildiumId: string | null = normId(r.AssociationOwnerId);
|
||||
let localOwner = resolvedOwnerBuildiumId ? ownerByBuildiumLocal.get(resolvedOwnerBuildiumId) : null;
|
||||
if (!localOwner && acctInfo) {
|
||||
for (const boid of acctInfo.ownerBuildiumIds) {
|
||||
const cand = ownerByBuildiumLocal.get(boid);
|
||||
if (cand) { localOwner = cand; resolvedOwnerBuildiumId = boid; break; }
|
||||
}
|
||||
// Even if no local owner exists yet, keep the first association-owner id so apply can
|
||||
// recover it after owners are imported in the same run.
|
||||
if (!resolvedOwnerBuildiumId && acctInfo.ownerBuildiumIds.length) {
|
||||
resolvedOwnerBuildiumId = acctInfo.ownerBuildiumIds[0];
|
||||
}
|
||||
}
|
||||
|
||||
const localUnit = buildiumUnitId
|
||||
? unitByBuildiumId.get(buildiumUnitId)
|
||||
: (localOwner?.unit_id ? { id: localOwner.unit_id } : null);
|
||||
@@ -478,9 +513,12 @@ Deno.serve(async (req) => {
|
||||
const submittedDate = r.SubmittedDateTime
|
||||
? String(r.SubmittedDateTime).split("T")[0]
|
||||
: (r.SubmittedDate ? String(r.SubmittedDate).split("T")[0] : null);
|
||||
const decisionDate = r.DecisionDateTime
|
||||
? String(r.DecisionDateTime).split("T")[0]
|
||||
: (r.DecisionDate ? String(r.DecisionDate).split("T")[0] : null);
|
||||
// Buildium's ARC resource has no decision-date field — the decision is recorded via the
|
||||
// last update, so use LastUpdatedDateTime as the review/decision date for finalized requests.
|
||||
const isFinalDecision = /approve|den|reject/i.test(String(r.Decision || ""));
|
||||
const decisionDate = isFinalDecision
|
||||
? (String(r.DecisionDateTime || r.LastUpdatedDateTime || "").split("T")[0] || null)
|
||||
: null;
|
||||
const decisionNotes = r.DecisionDescription
|
||||
? String(r.DecisionDescription)
|
||||
: (r.Decision && !/pending/i.test(String(r.Decision)) ? `Decision: ${r.Decision}` : null);
|
||||
@@ -527,21 +565,27 @@ Deno.serve(async (req) => {
|
||||
decision_notes: decisionNotes,
|
||||
buildium_arc_request_id: buildiumArcId,
|
||||
_resolve_unit_buildium_id: buildiumUnitId,
|
||||
_resolve_owner_buildium_id: buildiumOwnerId,
|
||||
_resolve_owner_buildium_id: resolvedOwnerBuildiumId,
|
||||
_arc_files: files,
|
||||
_arc_buildium_association_id: String(ba.Id),
|
||||
_arc_decider_name: deciderName,
|
||||
_arc_decider_date: deciderDate,
|
||||
_arc_decision: r.Decision || null,
|
||||
};
|
||||
|
||||
const match = arcByBuildium.get(`${assocLocalId}|${buildiumArcId}`);
|
||||
if (match) {
|
||||
// Never downgrade an existing owner/unit link to null when Buildium can't resolve one.
|
||||
if (!incoming.owner_id && match.owner_id) incoming.owner_id = match.owner_id;
|
||||
if (!incoming.unit_id && match.unit_id) incoming.unit_id = match.unit_id;
|
||||
const d = diff(match, {
|
||||
status: incoming.status,
|
||||
decision_notes: incoming.decision_notes,
|
||||
review_date: incoming.review_date,
|
||||
title: incoming.title,
|
||||
description: incoming.description,
|
||||
owner_id: incoming.owner_id,
|
||||
unit_id: incoming.unit_id,
|
||||
});
|
||||
if (Object.keys(d).length === 0 && (!includeArcFiles || files.length === 0)) continue;
|
||||
stage(
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Hostinger Reach — connection test. Validates the stored global API token by listing segments.
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
const REACH_BASE = "https://developers.hostinger.com/api/reach/v1";
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||
const json = (b: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(b), { status, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
|
||||
try {
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader) return json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const userClient = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_ANON_KEY")!,
|
||||
{ global: { headers: { Authorization: authHeader } } },
|
||||
);
|
||||
const admin = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);
|
||||
|
||||
const { data: { user } } = await userClient.auth.getUser();
|
||||
if (!user) return json({ error: "Unauthorized" }, 401);
|
||||
const { data: roles } = await admin.from("user_roles").select("role").eq("user_id", user.id);
|
||||
if (!(roles || []).some((r: any) => r.role === "admin")) return json({ error: "Admin only" }, 403);
|
||||
|
||||
// A token can be supplied in the body to test before saving; otherwise use the stored one.
|
||||
const body = await req.json().catch(() => ({}));
|
||||
let token: string | null = typeof body.api_token === "string" && body.api_token.trim() ? body.api_token.trim() : null;
|
||||
if (!token) {
|
||||
const { data: cfg } = await admin
|
||||
.from("hostinger_reach_config").select("api_token").order("updated_at", { ascending: false }).limit(1).maybeSingle();
|
||||
token = cfg?.api_token || null;
|
||||
}
|
||||
if (!token) return json({ success: false, error: "No Hostinger Reach API token configured." }, 400);
|
||||
|
||||
const res = await fetch(`${REACH_BASE}/segmentation/segments`, {
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
return json({ success: false, error: `Reach API ${res.status}: ${text.slice(0, 300)}` }, 200);
|
||||
}
|
||||
let parsed: any = {};
|
||||
try { parsed = JSON.parse(text); } catch { /* ignore */ }
|
||||
const list = Array.isArray(parsed) ? parsed : (parsed.data ?? parsed.segments ?? []);
|
||||
return json({ success: true, segment_count: Array.isArray(list) ? list.length : 0 });
|
||||
} catch (err) {
|
||||
return json({ success: false, error: (err as Error).message }, 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
// Hostinger Reach — sync an association's active owners into Reach as contacts, and ensure a
|
||||
// per-association segment (matched on the contact `note` marker) exists so the HOA is targetable.
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
const REACH_BASE = "https://developers.hostinger.com/api/reach/v1";
|
||||
|
||||
// Best-effort E.164 normalization; returns null if it can't be made valid (Reach rejects bad phones).
|
||||
function toE164(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
const trimmed = String(raw).trim();
|
||||
const hasPlus = trimmed.startsWith("+");
|
||||
const digits = trimmed.replace(/[^0-9]/g, "");
|
||||
if (digits.length < 7 || digits.length > 15) return null;
|
||||
if (hasPlus) return `+${digits}`;
|
||||
if (digits.length === 10) return `+1${digits}`; // assume US 10-digit
|
||||
if (digits.length === 11 && digits.startsWith("1")) return `+${digits}`;
|
||||
return null; // ambiguous → omit rather than risk an API error
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||
const json = (b: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(b), { status, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
|
||||
try {
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader) return json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const userClient = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_ANON_KEY")!,
|
||||
{ global: { headers: { Authorization: authHeader } } },
|
||||
);
|
||||
const admin = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);
|
||||
|
||||
const { data: { user } } = await userClient.auth.getUser();
|
||||
if (!user) return json({ error: "Unauthorized" }, 401);
|
||||
const { data: roles } = await admin.from("user_roles").select("role").eq("user_id", user.id);
|
||||
if (!(roles || []).some((r: any) => r.role === "admin")) return json({ error: "Admin only" }, 403);
|
||||
|
||||
const { association_id } = await req.json().catch(() => ({}));
|
||||
if (!association_id) return json({ error: "Missing association_id" }, 400);
|
||||
|
||||
const { data: cfg } = await admin
|
||||
.from("hostinger_reach_config").select("api_token").order("updated_at", { ascending: false }).limit(1).maybeSingle();
|
||||
const token = cfg?.api_token;
|
||||
if (!token) return json({ error: "No Hostinger Reach API token configured." }, 400);
|
||||
const authHeaders = { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json" };
|
||||
|
||||
const { data: assoc } = await admin.from("associations").select("name").eq("id", association_id).maybeSingle();
|
||||
const assocName = assoc?.name || `Association ${String(association_id).slice(0, 8)}`;
|
||||
const marker = `acmcc-assoc:${association_id}`;
|
||||
|
||||
const { data: owners, error: ownErr } = await admin
|
||||
.from("owners")
|
||||
.select("first_name, last_name, email, phone")
|
||||
.eq("association_id", association_id)
|
||||
.eq("status", "active")
|
||||
.not("email", "is", null);
|
||||
if (ownErr) return json({ error: ownErr.message }, 500);
|
||||
|
||||
const valid = (owners || []).filter((o: any) => o.email && String(o.email).includes("@"));
|
||||
|
||||
let succeeded = 0, failed = 0;
|
||||
let lastError: string | null = null;
|
||||
|
||||
for (const o of valid) {
|
||||
const payload: Record<string, unknown> = {
|
||||
email: String(o.email).trim(),
|
||||
name: o.first_name || undefined,
|
||||
surname: o.last_name || undefined,
|
||||
note: marker,
|
||||
};
|
||||
const phone = toE164(o.phone);
|
||||
if (phone) payload.phone = phone;
|
||||
|
||||
try {
|
||||
const r = await fetch(`${REACH_BASE}/contacts`, {
|
||||
method: "POST", headers: authHeaders, body: JSON.stringify(payload),
|
||||
});
|
||||
if (r.ok) { succeeded++; continue; }
|
||||
const txt = await r.text();
|
||||
// A contact that already exists is success for sync purposes.
|
||||
if (r.status === 409 || /exist|already|duplicate/i.test(txt)) { succeeded++; continue; }
|
||||
failed++; lastError = `${r.status}: ${txt.slice(0, 200)}`;
|
||||
} catch (e) {
|
||||
failed++; lastError = (e as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure a segment for this association.
|
||||
let segmentUuid: string | null = null;
|
||||
let segmentName: string | null = null;
|
||||
try {
|
||||
const { data: existing } = await admin
|
||||
.from("hostinger_reach_segments").select("segment_uuid, segment_name").eq("association_id", association_id).maybeSingle();
|
||||
segmentUuid = existing?.segment_uuid || null;
|
||||
segmentName = existing?.segment_name || null;
|
||||
|
||||
if (!segmentUuid) {
|
||||
// Look for an existing segment by name before creating a new one.
|
||||
const listRes = await fetch(`${REACH_BASE}/segmentation/segments`, { headers: authHeaders });
|
||||
if (listRes.ok) {
|
||||
const parsed = await listRes.json().catch(() => ({}));
|
||||
const list: any[] = Array.isArray(parsed) ? parsed : (parsed.data ?? parsed.segments ?? []);
|
||||
const match = list.find((s: any) => (s.name || s.title) === assocName);
|
||||
if (match) { segmentUuid = match.uuid || match.id || null; segmentName = match.name || assocName; }
|
||||
}
|
||||
}
|
||||
|
||||
if (!segmentUuid) {
|
||||
const createRes = await fetch(`${REACH_BASE}/segmentation/segments`, {
|
||||
method: "POST", headers: authHeaders,
|
||||
body: JSON.stringify({
|
||||
name: assocName,
|
||||
logic: "AND",
|
||||
conditions: [{ attribute: "note", operator: "equals", value: marker }],
|
||||
}),
|
||||
});
|
||||
if (createRes.ok) {
|
||||
const created = await createRes.json().catch(() => ({}));
|
||||
const seg = created.data ?? created.segment ?? created;
|
||||
segmentUuid = seg?.uuid || seg?.id || null;
|
||||
segmentName = seg?.name || assocName;
|
||||
} else {
|
||||
const txt = await createRes.text();
|
||||
lastError = lastError || `segment create ${createRes.status}: ${txt.slice(0, 200)}`;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
lastError = lastError || `segment: ${(e as Error).message}`;
|
||||
}
|
||||
|
||||
const status = valid.length === 0 ? "success" : (failed === 0 ? "success" : (succeeded === 0 ? "failed" : "partial"));
|
||||
await admin.from("hostinger_reach_segments").upsert({
|
||||
association_id,
|
||||
segment_uuid: segmentUuid,
|
||||
segment_name: segmentName || assocName,
|
||||
last_sync_at: new Date().toISOString(),
|
||||
last_sync_status: status,
|
||||
last_sync_count: succeeded,
|
||||
last_sync_error: lastError,
|
||||
}, { onConflict: "association_id" });
|
||||
|
||||
return json({ success: true, total: valid.length, succeeded, failed, segment_name: segmentName || assocName, error: lastError });
|
||||
} catch (err) {
|
||||
return json({ error: (err as Error).message }, 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Allow privileged backend contexts (service role / no JWT, e.g. the Buildium import) to update
|
||||
-- finalized ARC applications, alongside admins. Client writes by non-admins remain blocked by RLS,
|
||||
-- so this does not weaken the user-facing lock.
|
||||
CREATE OR REPLACE FUNCTION public.prevent_updates_on_finalized_arc()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
BEGIN
|
||||
IF lower(COALESCE(OLD.status,'')) IN ('approved','denied') THEN
|
||||
-- auth.uid() IS NULL => no end-user JWT (service role / backend job); admins also exempt.
|
||||
IF auth.uid() IS NULL OR public.has_role(auth.uid(), 'admin'::public.app_role) THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
RAISE EXCEPTION 'This ARC application has been finalized (approved or denied) and is locked from further changes.'
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Hostinger Reach integration: one global API token + per-association segment/sync tracking.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.hostinger_reach_config (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
api_token text NOT NULL,
|
||||
created_by uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.hostinger_reach_segments (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
association_id uuid NOT NULL UNIQUE REFERENCES public.associations(id) ON DELETE CASCADE,
|
||||
segment_uuid text,
|
||||
segment_name text,
|
||||
last_sync_at timestamptz,
|
||||
last_sync_status text,
|
||||
last_sync_count integer,
|
||||
last_sync_error text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE public.hostinger_reach_config ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.hostinger_reach_segments ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Admin-only access (service role bypasses RLS for the edge functions).
|
||||
CREATE POLICY "Admins manage reach config" ON public.hostinger_reach_config
|
||||
FOR ALL USING (public.has_role(auth.uid(), 'admin'::public.app_role))
|
||||
WITH CHECK (public.has_role(auth.uid(), 'admin'::public.app_role));
|
||||
|
||||
CREATE POLICY "Admins manage reach segments" ON public.hostinger_reach_segments
|
||||
FOR ALL USING (public.has_role(auth.uid(), 'admin'::public.app_role))
|
||||
WITH CHECK (public.has_role(auth.uid(), 'admin'::public.app_role));
|
||||
|
||||
CREATE TRIGGER set_reach_config_updated_at BEFORE UPDATE ON public.hostinger_reach_config
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
CREATE TRIGGER set_reach_segments_updated_at BEFORE UPDATE ON public.hostinger_reach_segments
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
Reference in New Issue
Block a user