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:
@@ -26,23 +26,8 @@ jobs:
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
deploy:
|
||||
# Auto-deploy to the VPS (avria.cloud) on every push to main.
|
||||
# The SSH key is restricted on the server (forced command): it can only
|
||||
# run /home/avria/deploy.sh, which pulls main, builds, and rsyncs
|
||||
# dist/ -> public_html. The command string below is therefore ignored
|
||||
# by the server but kept descriptive.
|
||||
needs: build
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy to VPS
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$VPS_DEPLOY_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
printf '%s\n' "$VPS_HOST_KEY" > ~/.ssh/known_hosts
|
||||
ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes avria@2.25.155.250 "deploy main"
|
||||
env:
|
||||
VPS_DEPLOY_KEY: ${{ secrets.VPS_DEPLOY_KEY }}
|
||||
VPS_HOST_KEY: ${{ secrets.VPS_HOST_KEY }}
|
||||
# Deployment: the VPS (avria.cloud) polls this repo every 2 minutes via
|
||||
# /home/avria/auto-deploy.sh (cron, user avria) and runs deploy.sh when main
|
||||
# moves — pull/build/rsync to public_html. CI here is a build check only;
|
||||
# pushing SSH from Actions doesn't work because Hostinger's edge firewall
|
||||
# blocks the runners' IPs.
|
||||
|
||||
+2
-2
@@ -123,7 +123,7 @@ import EmailSendersPage from "./pages/EmailSendersPage";
|
||||
import EmailTemplatesPage from "./pages/EmailTemplatesPage";
|
||||
import NotifyBoardPage from "./pages/NotifyBoardPage";
|
||||
import NotifyOwnersPage from "./pages/NotifyOwnersPage";
|
||||
import MailchimpPage from "./pages/MailchimpPage";
|
||||
import HostingerReachPage from "./pages/HostingerReachPage";
|
||||
import DataMigration from "./pages/DataMigration";
|
||||
import MediaLibraryPage from "./pages/MediaLibraryPage";
|
||||
import MigrationFieldsPage from "./pages/MigrationFieldsPage";
|
||||
@@ -419,7 +419,7 @@ const App = () => (
|
||||
<Route path="email-templates" element={<EmailTemplatesPage />} />
|
||||
<Route path="notify-board" element={<NotifyBoardPage />} />
|
||||
<Route path="notify-owners" element={<NotifyOwnersPage />} />
|
||||
<Route path="mailchimp" element={<MailchimpPage />} />
|
||||
<Route path="hostinger-reach" element={<HostingerReachPage />} />
|
||||
<Route path="messages" element={<MessagesPage />} />
|
||||
{/* Financial module */}
|
||||
<Route path="vendors" element={<VendorsPage />} />
|
||||
|
||||
@@ -50,7 +50,7 @@ export const SETTINGS_PAGES = [
|
||||
items: [
|
||||
{ path: "/dashboard/settings/buildium", title: "Buildium", icon: Building2 },
|
||||
{ path: "/dashboard/settings/stripe-accounts", title: "Payment Gateways", icon: CreditCard },
|
||||
{ path: "/dashboard/mailchimp", title: "Mailchimp", icon: Mail },
|
||||
{ path: "/dashboard/hostinger-reach", title: "Hostinger Reach", icon: Mail },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -14,6 +14,51 @@ export type Database = {
|
||||
}
|
||||
public: {
|
||||
Tables: {
|
||||
_coa_perassoc_backup: {
|
||||
Row: {
|
||||
account_name: string | null
|
||||
account_number: string | null
|
||||
account_type: string | null
|
||||
accounting_system: string | null
|
||||
association_id: string | null
|
||||
association_ids: string[] | null
|
||||
created_at: string | null
|
||||
description: string | null
|
||||
id: string | null
|
||||
is_active: boolean | null
|
||||
parent_account_id: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
Insert: {
|
||||
account_name?: string | null
|
||||
account_number?: string | null
|
||||
account_type?: string | null
|
||||
accounting_system?: string | null
|
||||
association_id?: string | null
|
||||
association_ids?: string[] | null
|
||||
created_at?: string | null
|
||||
description?: string | null
|
||||
id?: string | null
|
||||
is_active?: boolean | null
|
||||
parent_account_id?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
Update: {
|
||||
account_name?: string | null
|
||||
account_number?: string | null
|
||||
account_type?: string | null
|
||||
accounting_system?: string | null
|
||||
association_id?: string | null
|
||||
association_ids?: string[] | null
|
||||
created_at?: string | null
|
||||
description?: string | null
|
||||
id?: string | null
|
||||
is_active?: boolean | null
|
||||
parent_account_id?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
address_geocodes: {
|
||||
Row: {
|
||||
address: string
|
||||
@@ -133,6 +178,7 @@ export type Database = {
|
||||
show_on_public_page: boolean
|
||||
signable_documents: Json
|
||||
sort_order: number | null
|
||||
source_rv_lot_id: string | null
|
||||
updated_at: string
|
||||
visible_to_roles: Json
|
||||
}
|
||||
@@ -152,6 +198,7 @@ export type Database = {
|
||||
show_on_public_page?: boolean
|
||||
signable_documents?: Json
|
||||
sort_order?: number | null
|
||||
source_rv_lot_id?: string | null
|
||||
updated_at?: string
|
||||
visible_to_roles?: Json
|
||||
}
|
||||
@@ -171,6 +218,7 @@ export type Database = {
|
||||
show_on_public_page?: boolean
|
||||
signable_documents?: Json
|
||||
sort_order?: number | null
|
||||
source_rv_lot_id?: string | null
|
||||
updated_at?: string
|
||||
visible_to_roles?: Json
|
||||
}
|
||||
@@ -724,6 +772,24 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
"arc-committee-reviews": {
|
||||
Row: {
|
||||
created_at: string
|
||||
id: number
|
||||
user_id: string | null
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
id?: number
|
||||
user_id?: string | null
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
id?: number
|
||||
user_id?: string | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
association_custom_fields: {
|
||||
Row: {
|
||||
association_id: string
|
||||
@@ -1105,6 +1171,7 @@ export type Database = {
|
||||
}
|
||||
associations: {
|
||||
Row: {
|
||||
accounting_system: string
|
||||
address: string | null
|
||||
attorney_email: string | null
|
||||
attorney_firm: string | null
|
||||
@@ -1137,6 +1204,7 @@ export type Database = {
|
||||
zoho_organization_id: string | null
|
||||
}
|
||||
Insert: {
|
||||
accounting_system?: string
|
||||
address?: string | null
|
||||
attorney_email?: string | null
|
||||
attorney_firm?: string | null
|
||||
@@ -1169,6 +1237,7 @@ export type Database = {
|
||||
zoho_organization_id?: string | null
|
||||
}
|
||||
Update: {
|
||||
accounting_system?: string
|
||||
address?: string | null
|
||||
attorney_email?: string | null
|
||||
attorney_firm?: string | null
|
||||
@@ -1555,6 +1624,8 @@ export type Database = {
|
||||
created_at: string
|
||||
created_by: string | null
|
||||
description: string | null
|
||||
document_name: string | null
|
||||
document_url: string | null
|
||||
expiry_date: string | null
|
||||
id: string
|
||||
project_id: string | null
|
||||
@@ -1569,6 +1640,8 @@ export type Database = {
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
description?: string | null
|
||||
document_name?: string | null
|
||||
document_url?: string | null
|
||||
expiry_date?: string | null
|
||||
id?: string
|
||||
project_id?: string | null
|
||||
@@ -1583,6 +1656,8 @@ export type Database = {
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
description?: string | null
|
||||
document_name?: string | null
|
||||
document_url?: string | null
|
||||
expiry_date?: string | null
|
||||
id?: string
|
||||
project_id?: string | null
|
||||
@@ -2133,6 +2208,7 @@ export type Database = {
|
||||
Row: {
|
||||
approval_authority: boolean
|
||||
association_id: string
|
||||
can_upload: boolean
|
||||
created_at: string
|
||||
id: string
|
||||
member_email: string | null
|
||||
@@ -2145,6 +2221,7 @@ export type Database = {
|
||||
Insert: {
|
||||
approval_authority?: boolean
|
||||
association_id: string
|
||||
can_upload?: boolean
|
||||
created_at?: string
|
||||
id?: string
|
||||
member_email?: string | null
|
||||
@@ -2157,6 +2234,7 @@ export type Database = {
|
||||
Update: {
|
||||
approval_authority?: boolean
|
||||
association_id?: string
|
||||
can_upload?: boolean
|
||||
created_at?: string
|
||||
id?: string
|
||||
member_email?: string | null
|
||||
@@ -2803,6 +2881,8 @@ export type Database = {
|
||||
id: string
|
||||
logo_url: string | null
|
||||
memo_prefix: string | null
|
||||
micr_gap_1: number
|
||||
micr_gap_2: number
|
||||
offset_x: number
|
||||
offset_y: number
|
||||
payer_address: string | null
|
||||
@@ -2825,6 +2905,8 @@ export type Database = {
|
||||
id?: string
|
||||
logo_url?: string | null
|
||||
memo_prefix?: string | null
|
||||
micr_gap_1?: number
|
||||
micr_gap_2?: number
|
||||
offset_x?: number
|
||||
offset_y?: number
|
||||
payer_address?: string | null
|
||||
@@ -2847,6 +2929,8 @@ export type Database = {
|
||||
id?: string
|
||||
logo_url?: string | null
|
||||
memo_prefix?: string | null
|
||||
micr_gap_1?: number
|
||||
micr_gap_2?: number
|
||||
offset_x?: number
|
||||
offset_y?: number
|
||||
payer_address?: string | null
|
||||
@@ -3451,6 +3535,8 @@ export type Database = {
|
||||
id: string
|
||||
logo_url: string | null
|
||||
memo_prefix: string | null
|
||||
micr_gap_1: number
|
||||
micr_gap_2: number
|
||||
offset_x: number
|
||||
offset_y: number
|
||||
payer_address: string | null
|
||||
@@ -3472,6 +3558,8 @@ export type Database = {
|
||||
id?: string
|
||||
logo_url?: string | null
|
||||
memo_prefix?: string | null
|
||||
micr_gap_1?: number
|
||||
micr_gap_2?: number
|
||||
offset_x?: number
|
||||
offset_y?: number
|
||||
payer_address?: string | null
|
||||
@@ -3493,6 +3581,8 @@ export type Database = {
|
||||
id?: string
|
||||
logo_url?: string | null
|
||||
memo_prefix?: string | null
|
||||
micr_gap_1?: number
|
||||
micr_gap_2?: number
|
||||
offset_x?: number
|
||||
offset_y?: number
|
||||
payer_address?: string | null
|
||||
@@ -4352,12 +4442,18 @@ export type Database = {
|
||||
email_headers: Json | null
|
||||
feature_type: string | null
|
||||
id: string
|
||||
last_open_ip: string | null
|
||||
last_open_user_agent: string | null
|
||||
last_opened_at: string | null
|
||||
open_count: number
|
||||
opened_at: string | null
|
||||
proof_url: string | null
|
||||
recipient_email: string | null
|
||||
sender_email: string | null
|
||||
sent_at: string | null
|
||||
status: string | null
|
||||
subject: string | null
|
||||
tracking_id: string | null
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
@@ -4366,12 +4462,18 @@ export type Database = {
|
||||
email_headers?: Json | null
|
||||
feature_type?: string | null
|
||||
id?: string
|
||||
last_open_ip?: string | null
|
||||
last_open_user_agent?: string | null
|
||||
last_opened_at?: string | null
|
||||
open_count?: number
|
||||
opened_at?: string | null
|
||||
proof_url?: string | null
|
||||
recipient_email?: string | null
|
||||
sender_email?: string | null
|
||||
sent_at?: string | null
|
||||
status?: string | null
|
||||
subject?: string | null
|
||||
tracking_id?: string | null
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
@@ -4380,12 +4482,18 @@ export type Database = {
|
||||
email_headers?: Json | null
|
||||
feature_type?: string | null
|
||||
id?: string
|
||||
last_open_ip?: string | null
|
||||
last_open_user_agent?: string | null
|
||||
last_opened_at?: string | null
|
||||
open_count?: number
|
||||
opened_at?: string | null
|
||||
proof_url?: string | null
|
||||
recipient_email?: string | null
|
||||
sender_email?: string | null
|
||||
sent_at?: string | null
|
||||
status?: string | null
|
||||
subject?: string | null
|
||||
tracking_id?: string | null
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: []
|
||||
@@ -5203,6 +5311,77 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
hostinger_reach_config: {
|
||||
Row: {
|
||||
api_token: string
|
||||
created_at: string
|
||||
created_by: string | null
|
||||
id: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
api_token: string
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
id?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
api_token?: string
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
id?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
hostinger_reach_segments: {
|
||||
Row: {
|
||||
association_id: string
|
||||
created_at: string
|
||||
id: string
|
||||
last_sync_at: string | null
|
||||
last_sync_count: number | null
|
||||
last_sync_error: string | null
|
||||
last_sync_status: string | null
|
||||
segment_name: string | null
|
||||
segment_uuid: string | null
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
association_id: string
|
||||
created_at?: string
|
||||
id?: string
|
||||
last_sync_at?: string | null
|
||||
last_sync_count?: number | null
|
||||
last_sync_error?: string | null
|
||||
last_sync_status?: string | null
|
||||
segment_name?: string | null
|
||||
segment_uuid?: string | null
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
association_id?: string
|
||||
created_at?: string
|
||||
id?: string
|
||||
last_sync_at?: string | null
|
||||
last_sync_count?: number | null
|
||||
last_sync_error?: string | null
|
||||
last_sync_status?: string | null
|
||||
segment_name?: string | null
|
||||
segment_uuid?: string | null
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "hostinger_reach_segments_association_id_fkey"
|
||||
columns: ["association_id"]
|
||||
isOneToOne: true
|
||||
referencedRelation: "associations"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
in_app_notifications: {
|
||||
Row: {
|
||||
created_at: string
|
||||
@@ -7173,12 +7352,42 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
rv_boat_lot_maps: {
|
||||
Row: {
|
||||
association_id: string
|
||||
config: Json
|
||||
updated_at: string | null
|
||||
}
|
||||
Insert: {
|
||||
association_id: string
|
||||
config?: Json
|
||||
updated_at?: string | null
|
||||
}
|
||||
Update: {
|
||||
association_id?: string
|
||||
config?: Json
|
||||
updated_at?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "rv_boat_lot_maps_association_id_fkey"
|
||||
columns: ["association_id"]
|
||||
isOneToOne: true
|
||||
referencedRelation: "associations"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
rv_boat_lot_rentals: {
|
||||
Row: {
|
||||
association_id: string
|
||||
created_at: string
|
||||
end_date: string | null
|
||||
id: string
|
||||
insurance_carrier: string | null
|
||||
insurance_document_url: string | null
|
||||
insurance_expiration_date: string | null
|
||||
insurance_policy_number: string | null
|
||||
is_owner: boolean
|
||||
lot_id: string
|
||||
monthly_rate: number | null
|
||||
@@ -7199,6 +7408,10 @@ export type Database = {
|
||||
created_at?: string
|
||||
end_date?: string | null
|
||||
id?: string
|
||||
insurance_carrier?: string | null
|
||||
insurance_document_url?: string | null
|
||||
insurance_expiration_date?: string | null
|
||||
insurance_policy_number?: string | null
|
||||
is_owner?: boolean
|
||||
lot_id: string
|
||||
monthly_rate?: number | null
|
||||
@@ -7219,6 +7432,10 @@ export type Database = {
|
||||
created_at?: string
|
||||
end_date?: string | null
|
||||
id?: string
|
||||
insurance_carrier?: string | null
|
||||
insurance_document_url?: string | null
|
||||
insurance_expiration_date?: string | null
|
||||
insurance_policy_number?: string | null
|
||||
is_owner?: boolean
|
||||
lot_id?: string
|
||||
monthly_rate?: number | null
|
||||
@@ -7278,6 +7495,7 @@ export type Database = {
|
||||
owner_id: string | null
|
||||
position: number
|
||||
requested_lot_type: string | null
|
||||
requested_size: string | null
|
||||
requester_email: string | null
|
||||
requester_name: string
|
||||
requester_phone: string | null
|
||||
@@ -7300,6 +7518,7 @@ export type Database = {
|
||||
owner_id?: string | null
|
||||
position: number
|
||||
requested_lot_type?: string | null
|
||||
requested_size?: string | null
|
||||
requester_email?: string | null
|
||||
requester_name: string
|
||||
requester_phone?: string | null
|
||||
@@ -7322,6 +7541,7 @@ export type Database = {
|
||||
owner_id?: string | null
|
||||
position?: number
|
||||
requested_lot_type?: string | null
|
||||
requested_size?: string | null
|
||||
requester_email?: string | null
|
||||
requester_name?: string
|
||||
requester_phone?: string | null
|
||||
@@ -7480,6 +7700,50 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
rv_renter_insurance_requests: {
|
||||
Row: {
|
||||
created_at: string | null
|
||||
created_by: string | null
|
||||
expires_at: string | null
|
||||
id: string
|
||||
rental_id: string
|
||||
sent_at: string | null
|
||||
sent_to_email: string | null
|
||||
submitted_at: string | null
|
||||
token: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string | null
|
||||
created_by?: string | null
|
||||
expires_at?: string | null
|
||||
id?: string
|
||||
rental_id: string
|
||||
sent_at?: string | null
|
||||
sent_to_email?: string | null
|
||||
submitted_at?: string | null
|
||||
token?: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string | null
|
||||
created_by?: string | null
|
||||
expires_at?: string | null
|
||||
id?: string
|
||||
rental_id?: string
|
||||
sent_at?: string | null
|
||||
sent_to_email?: string | null
|
||||
submitted_at?: string | null
|
||||
token?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "rv_renter_insurance_requests_rental_id_fkey"
|
||||
columns: ["rental_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "rv_boat_lot_rentals"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
saved_form_templates: {
|
||||
Row: {
|
||||
association_id: string | null
|
||||
@@ -9001,6 +9265,7 @@ export type Database = {
|
||||
association_id: string
|
||||
bathrooms: number | null
|
||||
bedrooms: number | null
|
||||
buildium_account_number: string | null
|
||||
buildium_unit_id: string | null
|
||||
city: string | null
|
||||
created_at: string
|
||||
@@ -9028,6 +9293,7 @@ export type Database = {
|
||||
association_id: string
|
||||
bathrooms?: number | null
|
||||
bedrooms?: number | null
|
||||
buildium_account_number?: string | null
|
||||
buildium_unit_id?: string | null
|
||||
city?: string | null
|
||||
created_at?: string
|
||||
@@ -9055,6 +9321,7 @@ export type Database = {
|
||||
association_id?: string
|
||||
bathrooms?: number | null
|
||||
bedrooms?: number | null
|
||||
buildium_account_number?: string | null
|
||||
buildium_unit_id?: string | null
|
||||
city?: string | null
|
||||
created_at?: string
|
||||
@@ -9125,7 +9392,7 @@ export type Database = {
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
role: Database["public"]["Enums"]["app_role"]
|
||||
role?: Database["public"]["Enums"]["app_role"]
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
@@ -9356,6 +9623,7 @@ export type Database = {
|
||||
phone: string | null
|
||||
profile_last_submitted_at: string | null
|
||||
remittance_address: string | null
|
||||
share_with_board: boolean
|
||||
tax_id: string | null
|
||||
updated_at: string
|
||||
w9_document_url: string | null
|
||||
@@ -9390,6 +9658,7 @@ export type Database = {
|
||||
phone?: string | null
|
||||
profile_last_submitted_at?: string | null
|
||||
remittance_address?: string | null
|
||||
share_with_board?: boolean
|
||||
tax_id?: string | null
|
||||
updated_at?: string
|
||||
w9_document_url?: string | null
|
||||
@@ -9424,6 +9693,7 @@ export type Database = {
|
||||
phone?: string | null
|
||||
profile_last_submitted_at?: string | null
|
||||
remittance_address?: string | null
|
||||
share_with_board?: boolean
|
||||
tax_id?: string | null
|
||||
updated_at?: string
|
||||
w9_document_url?: string | null
|
||||
@@ -9503,9 +9773,12 @@ export type Database = {
|
||||
category: string
|
||||
citation: string | null
|
||||
created_at: string | null
|
||||
cure_days: number | null
|
||||
description: string | null
|
||||
id: string
|
||||
letter_templates: string | null
|
||||
requested_action: string | null
|
||||
stage_count: number | null
|
||||
updated_at: string | null
|
||||
}
|
||||
Insert: {
|
||||
@@ -9514,9 +9787,12 @@ export type Database = {
|
||||
category: string
|
||||
citation?: string | null
|
||||
created_at?: string | null
|
||||
cure_days?: number | null
|
||||
description?: string | null
|
||||
id?: string
|
||||
letter_templates?: string | null
|
||||
requested_action?: string | null
|
||||
stage_count?: number | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
Update: {
|
||||
@@ -9525,9 +9801,12 @@ export type Database = {
|
||||
category?: string
|
||||
citation?: string | null
|
||||
created_at?: string | null
|
||||
cure_days?: number | null
|
||||
description?: string | null
|
||||
id?: string
|
||||
letter_templates?: string | null
|
||||
requested_action?: string | null
|
||||
stage_count?: number | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
@@ -9546,6 +9825,7 @@ export type Database = {
|
||||
assigned_to: string | null
|
||||
association_id: string
|
||||
category: string | null
|
||||
certified_mail: string | null
|
||||
created_at: string
|
||||
created_by: string | null
|
||||
description: string | null
|
||||
@@ -9575,6 +9855,7 @@ export type Database = {
|
||||
assigned_to?: string | null
|
||||
association_id: string
|
||||
category?: string | null
|
||||
certified_mail?: string | null
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
description?: string | null
|
||||
@@ -9604,6 +9885,7 @@ export type Database = {
|
||||
assigned_to?: string | null
|
||||
association_id?: string
|
||||
category?: string | null
|
||||
certified_mail?: string | null
|
||||
created_at?: string
|
||||
created_by?: string | null
|
||||
description?: string | null
|
||||
@@ -9982,6 +10264,8 @@ export type Database = {
|
||||
}
|
||||
Functions: {
|
||||
_get_vendor_ach_key: { Args: never; Returns: string }
|
||||
avria_norm_addr: { Args: { p: string }; Returns: string }
|
||||
avria_normalize_key: { Args: { input: string }; Returns: string }
|
||||
calculate_legal_matter_ledger_amount: {
|
||||
Args: { p_legal_matter_id: string }
|
||||
Returns: number
|
||||
@@ -10016,6 +10300,10 @@ export type Database = {
|
||||
Args: { payload: Json; queue_name: string }
|
||||
Returns: number
|
||||
}
|
||||
ensure_accounting_vendor: {
|
||||
Args: { _association_id: string; _public_vendor_id: string }
|
||||
Returns: string
|
||||
}
|
||||
get_association_accounting_system: {
|
||||
Args: { _association_id: string }
|
||||
Returns: string
|
||||
@@ -10037,7 +10325,6 @@ export type Database = {
|
||||
Args: { _user_id?: string }
|
||||
Returns: string[]
|
||||
}
|
||||
get_maintenance_status: { Args: never; Returns: Json }
|
||||
get_master_board_association_ids: {
|
||||
Args: { _user_id?: string }
|
||||
Returns: string[]
|
||||
@@ -10149,6 +10436,7 @@ export type Database = {
|
||||
}
|
||||
Returns: boolean
|
||||
}
|
||||
is_management_staff: { Args: { _user_id?: string }; Returns: boolean }
|
||||
is_master_board_member_of_association: {
|
||||
Args: { _association_id: string; _user_id: string }
|
||||
Returns: boolean
|
||||
@@ -10193,6 +10481,16 @@ export type Database = {
|
||||
}[]
|
||||
}
|
||||
lookup_email_by_username: { Args: { _username: string }; Returns: string }
|
||||
lookup_rv_renter_insurance_request: {
|
||||
Args: { p_token: string }
|
||||
Returns: {
|
||||
expires_at: string
|
||||
rental_id: string
|
||||
renter_name: string
|
||||
request_id: string
|
||||
submitted_at: string
|
||||
}[]
|
||||
}
|
||||
lookup_signup_code: {
|
||||
Args: { p_code: string }
|
||||
Returns: {
|
||||
@@ -10305,6 +10603,13 @@ export type Database = {
|
||||
}
|
||||
Returns: number
|
||||
}
|
||||
postgres_fdw_disconnect: { Args: { "": string }; Returns: boolean }
|
||||
postgres_fdw_disconnect_all: { Args: never; Returns: boolean }
|
||||
postgres_fdw_get_connections: {
|
||||
Args: never
|
||||
Returns: Record<string, unknown>[]
|
||||
}
|
||||
postgres_fdw_handler: { Args: never; Returns: unknown }
|
||||
read_email_batch: {
|
||||
Args: { batch_size: number; queue_name: string; vt: number }
|
||||
Returns: {
|
||||
@@ -10322,10 +10627,30 @@ export type Database = {
|
||||
Args: { p_option: string; p_token: string }
|
||||
Returns: Json
|
||||
}
|
||||
record_email_open:
|
||||
| { Args: { _tracking_id: string }; Returns: boolean }
|
||||
| {
|
||||
Args: {
|
||||
p_ip?: string
|
||||
p_tracking_id: string
|
||||
p_user_agent?: string
|
||||
}
|
||||
Returns: undefined
|
||||
}
|
||||
refresh_committee_member_active_status: {
|
||||
Args: never
|
||||
Returns: undefined
|
||||
}
|
||||
submit_rv_renter_insurance: {
|
||||
Args: {
|
||||
p_carrier: string
|
||||
p_document_url?: string
|
||||
p_expiration_date: string
|
||||
p_policy_number: string
|
||||
p_token: string
|
||||
}
|
||||
Returns: boolean
|
||||
}
|
||||
submit_tenant_info: {
|
||||
Args: {
|
||||
p_email?: string
|
||||
@@ -10393,18 +10718,21 @@ export type Database = {
|
||||
| "admin"
|
||||
| "manager"
|
||||
| "homeowner"
|
||||
| "staff"
|
||||
| "employee"
|
||||
| "board_member"
|
||||
| "arc_member"
|
||||
| "fining_member"
|
||||
| "association_management"
|
||||
| "legal"
|
||||
| "rv_boat_lot"
|
||||
| "fining_member"
|
||||
| "management"
|
||||
| "staff"
|
||||
| "employee"
|
||||
| "master_board_member"
|
||||
| "rv_renter"
|
||||
| "rv_owner"
|
||||
| "rv_boat_lot"
|
||||
| "management"
|
||||
| "association_management"
|
||||
budget_entry_type: "account" | "customer_project"
|
||||
budget_period_type: "monthly" | "quarterly" | "annually"
|
||||
budget_status: "draft" | "active"
|
||||
fee_exclusion_mode: "waive" | "override_amount" | "override_percent"
|
||||
fee_exclusion_type: "late_fee" | "interest"
|
||||
}
|
||||
@@ -10538,19 +10866,22 @@ export const Constants = {
|
||||
"admin",
|
||||
"manager",
|
||||
"homeowner",
|
||||
"staff",
|
||||
"employee",
|
||||
"board_member",
|
||||
"arc_member",
|
||||
"fining_member",
|
||||
"association_management",
|
||||
"legal",
|
||||
"rv_boat_lot",
|
||||
"fining_member",
|
||||
"management",
|
||||
"staff",
|
||||
"employee",
|
||||
"master_board_member",
|
||||
"rv_renter",
|
||||
"rv_owner",
|
||||
"rv_boat_lot",
|
||||
"management",
|
||||
"association_management",
|
||||
],
|
||||
budget_entry_type: ["account", "customer_project"],
|
||||
budget_period_type: ["monthly", "quarterly", "annually"],
|
||||
budget_status: ["draft", "active"],
|
||||
fee_exclusion_mode: ["waive", "override_amount", "override_percent"],
|
||||
fee_exclusion_type: ["late_fee", "interest"],
|
||||
},
|
||||
|
||||
@@ -19,8 +19,12 @@ import { useDropzone } from "react-dropzone";
|
||||
import {
|
||||
Plus, Search, Gavel, Filter, FileText, Calendar, Building2, User,
|
||||
Home, Clock, ChevronRight, CheckCircle2, XCircle, AlertCircle,
|
||||
MessageSquare, Image, Paperclip, RefreshCw, Download, UploadCloud, X, File,
|
||||
MessageSquare, Image, Paperclip, RefreshCw, Download, UploadCloud, X, File, Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import type { Tables } from "@/integrations/supabase/types";
|
||||
import { format } from "date-fns";
|
||||
import VotingAndComments from "@/components/shared/VotingAndComments";
|
||||
@@ -364,6 +368,29 @@ export default function ARCApplicationsPage({ boardAssociationIds }: { boardAsso
|
||||
}
|
||||
};
|
||||
|
||||
/* ── Delete ─── */
|
||||
const handleDelete = async (app: AppRow) => {
|
||||
try {
|
||||
// Clean up generic vote/comment rows (keyed by entity, no FK cascade)
|
||||
await supabase.from("entity_votes").delete().eq("entity_type", "arc_application").eq("entity_id", app.id);
|
||||
await supabase.from("entity_comments").delete().eq("entity_type", "arc_application").eq("entity_id", app.id);
|
||||
// Remove any attachments from storage
|
||||
const prefix = `${app.association_id}/${app.id}`;
|
||||
const { data: storedFiles } = await supabase.storage.from("arc-files").list(prefix, { limit: 200 });
|
||||
if (storedFiles && storedFiles.length > 0) {
|
||||
await supabase.storage.from("arc-files").remove(storedFiles.map((f) => `${prefix}/${f.name}`));
|
||||
}
|
||||
// arc_application_comments / arc_application_votes cascade via FK; inbound emails are SET NULL
|
||||
const { error } = await supabase.from("arc_applications").delete().eq("id", app.id);
|
||||
if (error) throw error;
|
||||
toast({ title: "Application deleted" });
|
||||
setSelectedApp((prev) => (prev && prev.id === app.id ? null : prev));
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
toast({ title: "Error", description: err.message, variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const ownerName = (app: AppRow) =>
|
||||
app.owners ? `${app.owners.first_name} ${app.owners.last_name}` : "—";
|
||||
|
||||
@@ -778,6 +805,7 @@ export default function ARCApplicationsPage({ boardAssociationIds }: { boardAsso
|
||||
<ARCDetailPanel
|
||||
app={selectedApp}
|
||||
onStatusChange={handleStatusChange}
|
||||
onDelete={handleDelete}
|
||||
onClose={() => setSelectedApp(null)}
|
||||
onRefresh={fetchData}
|
||||
/>
|
||||
@@ -795,11 +823,13 @@ export default function ARCApplicationsPage({ boardAssociationIds }: { boardAsso
|
||||
function ARCDetailPanel({
|
||||
app,
|
||||
onStatusChange,
|
||||
onDelete,
|
||||
onClose,
|
||||
onRefresh,
|
||||
}: {
|
||||
app: AppRow;
|
||||
onStatusChange: (id: string, status: string, notes?: string) => void;
|
||||
onDelete: (app: AppRow) => void;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
@@ -924,6 +954,32 @@ function ARCDetailPanel({
|
||||
>
|
||||
<Download className="h-3 w-3" /> Export Record
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="outline" className="h-7 text-[11px] gap-1 text-destructive hover:text-destructive">
|
||||
<Trash2 className="h-3 w-3" /> Delete
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete this ARC application?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This permanently removes “{app.title}” along with its votes, comments, and attached files. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => onDelete(app)}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{app.submitted_date ? format(new Date(app.submitted_date), "MMMM d, yyyy") : "—"}
|
||||
</span>
|
||||
|
||||
@@ -41,10 +41,6 @@ export default function ComposeEmailPage() {
|
||||
const [sending, setSending] = useState(false);
|
||||
const [tab, setTab] = useState("content");
|
||||
const [pickerOpen, setPickerOpen] = useState<null | "to" | "cc" | "bcc">(null);
|
||||
const [sendMethod, setSendMethod] = useState<"smtp" | "mailchimp">("smtp");
|
||||
const [mailchimpConfig, setMailchimpConfig] = useState<any>(null);
|
||||
const [mcFromName, setMcFromName] = useState("");
|
||||
const [mcReplyTo, setMcReplyTo] = useState("");
|
||||
const [signatures, setSignatures] = useState<EmailSignature[]>([]);
|
||||
const [signatureId, setSignatureId] = useState<string>("none");
|
||||
const [signatureHtml, setSignatureHtml] = useState<string>("");
|
||||
@@ -87,23 +83,6 @@ export default function ComposeEmailPage() {
|
||||
if (name) setSubject(`Message for ${name}`);
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMc = async () => {
|
||||
if (!selectedAssociation?.id) { setMailchimpConfig(null); return; }
|
||||
const { data } = await supabase
|
||||
.from("mailchimp_configs")
|
||||
.select("*")
|
||||
.eq("association_id", selectedAssociation.id)
|
||||
.maybeSingle();
|
||||
setMailchimpConfig(data || null);
|
||||
if (data) {
|
||||
setMcFromName((prev) => prev || selectedAssociation?.name || "");
|
||||
setMcReplyTo((prev) => prev || user?.email || "");
|
||||
}
|
||||
};
|
||||
fetchMc();
|
||||
}, [selectedAssociation?.id, user?.email]);
|
||||
|
||||
const fetchSenders = async () => {
|
||||
const { data: result } = await supabase.functions.invoke("send-smtp-email", {
|
||||
body: { action: "list_senders" },
|
||||
@@ -170,53 +149,7 @@ export default function ComposeEmailPage() {
|
||||
if (e.target.files) setAttachments(Array.from(e.target.files));
|
||||
};
|
||||
|
||||
const handleSendMailchimp = async (sendNow: boolean) => {
|
||||
if (!selectedAssociation?.id) {
|
||||
toast({ variant: "destructive", title: "No association", description: "Select an association first." });
|
||||
return;
|
||||
}
|
||||
if (!mailchimpConfig?.audience_id) {
|
||||
toast({ variant: "destructive", title: "Mailchimp not configured", description: "Set up Mailchimp + audience under Mailchimp settings." });
|
||||
return;
|
||||
}
|
||||
if (!subject.trim() || !content.trim() || !mcFromName.trim() || !mcReplyTo.trim()) {
|
||||
toast({ variant: "destructive", title: "Missing fields", description: "Subject, content, From name, and Reply-to are required." });
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke("mailchimp-campaign", {
|
||||
body: {
|
||||
association_id: selectedAssociation.id,
|
||||
subject,
|
||||
from_name: mcFromName,
|
||||
reply_to: mcReplyTo,
|
||||
html: composeBodyWithSignature(content),
|
||||
send_now: sendNow,
|
||||
},
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!data?.success) throw new Error(data?.error || "Mailchimp send failed");
|
||||
toast({
|
||||
title: sendNow ? "Campaign Sent" : "Draft Saved",
|
||||
description: sendNow
|
||||
? `Sent to your Mailchimp audience.`
|
||||
: `Draft saved in Mailchimp (campaign ${data.campaign_id}).`,
|
||||
});
|
||||
if (sendNow) {
|
||||
setSubject(""); setContent(""); setTemplateId("none");
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast({ variant: "destructive", title: "Mailchimp Error", description: err.message });
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (sendMethod === "mailchimp") {
|
||||
return handleSendMailchimp(true);
|
||||
}
|
||||
if (!senderId || !recipients.trim() || !subject.trim()) {
|
||||
toast({ variant: "destructive", title: "Validation Error", description: "Sender, recipients, and subject are required." });
|
||||
return;
|
||||
@@ -331,34 +264,8 @@ export default function ComposeEmailPage() {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Send Method</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Select value={sendMethod} onValueChange={(v) => setSendMethod(v as "smtp" | "mailchimp")}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="smtp">SMTP (direct)</SelectItem>
|
||||
<SelectItem value="mailchimp" disabled={!mailchimpConfig?.audience_id}>
|
||||
Mailchimp Campaign {mailchimpConfig?.audience_id ? "" : "(not configured)"}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{sendMethod === "mailchimp" && !mailchimpConfig?.audience_id && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure Mailchimp and select an audience under the Mailchimp page first.
|
||||
</p>
|
||||
)}
|
||||
{sendMethod === "mailchimp" && mailchimpConfig?.audience_id && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Will send to your Mailchimp audience for <strong>{selectedAssociation?.name}</strong>. To/Cc/Bcc and SMTP sender are ignored.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>{sendMethod === "mailchimp" ? "Campaign Details" : "Sender & Recipients"}</CardTitle></CardHeader>
|
||||
<CardHeader><CardTitle>Sender & Recipients</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{sendMethod === "smtp" ? (
|
||||
<div className="space-y-2">
|
||||
<Label>From <span className="text-destructive">*</span></Label>
|
||||
<Select value={senderId} onValueChange={setSenderId}>
|
||||
@@ -370,19 +277,7 @@ export default function ComposeEmailPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>From Name <span className="text-destructive">*</span></Label>
|
||||
<Input value={mcFromName} onChange={(e) => setMcFromName(e.target.value)} placeholder="Association Name" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Reply-to Email <span className="text-destructive">*</span></Label>
|
||||
<Input type="email" value={mcReplyTo} onChange={(e) => setMcReplyTo(e.target.value)} placeholder="reply@example.com" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{sendMethod === "smtp" && (
|
||||
{(
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
@@ -543,15 +438,9 @@ export default function ComposeEmailPage() {
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={() => { setRecipients(""); setCc(""); setBcc(""); setShowCc(false); setShowBcc(false); setSubject(""); setContent(""); setAttachments([]); setTemplateId("none"); }}>Cancel</Button>
|
||||
{sendMethod === "mailchimp" && (
|
||||
<Button variant="secondary" onClick={() => handleSendMailchimp(false)} disabled={sending || uploading}>
|
||||
{sending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Mail className="h-4 w-4 mr-2" />}
|
||||
Save as Draft in Mailchimp
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleSend} disabled={sending || uploading}>
|
||||
{sending || uploading ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : (sendMethod === "mailchimp" ? <Mail className="h-4 w-4 mr-2" /> : <Send className="h-4 w-4 mr-2" />)}
|
||||
{sending || uploading ? "Sending..." : (sendMethod === "mailchimp" ? "Send via Mailchimp" : "Send Email")}
|
||||
{sending || uploading ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Send className="h-4 w-4 mr-2" />}
|
||||
{sending || uploading ? "Sending..." : "Send Email"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Loader2, Mail, RefreshCw, CheckCircle2, AlertCircle, Plug, Send, ExternalLink, Users } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface Association { id: string; name: string; }
|
||||
interface SegmentRow {
|
||||
association_id: string;
|
||||
segment_uuid: string | null;
|
||||
segment_name: string | null;
|
||||
last_sync_at: string | null;
|
||||
last_sync_status: string | null;
|
||||
last_sync_count: number | null;
|
||||
last_sync_error: string | null;
|
||||
}
|
||||
|
||||
const REACH_DASHBOARD_URL = "https://hpanel.hostinger.com/reach";
|
||||
|
||||
export default function HostingerReachPage() {
|
||||
const { toast } = useToast();
|
||||
const [associations, setAssociations] = useState<Association[]>([]);
|
||||
const [selectedAssoc, setSelectedAssoc] = useState<string>("");
|
||||
|
||||
// Global token config
|
||||
const [configId, setConfigId] = useState<string | null>(null);
|
||||
const [apiToken, setApiToken] = useState("");
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [savingToken, setSavingToken] = useState(false);
|
||||
|
||||
// Per-association sync state
|
||||
const [ownerCount, setOwnerCount] = useState<number>(0);
|
||||
const [segment, setSegment] = useState<SegmentRow | null>(null);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const { data } = await supabase.from("associations").select("id, name").eq("status", "active").order("name");
|
||||
setAssociations(data || []);
|
||||
if (data && data.length > 0) setSelectedAssoc(data[0].id);
|
||||
const { data: cfg } = await supabase
|
||||
.from("hostinger_reach_config").select("id").order("updated_at", { ascending: false }).limit(1).maybeSingle();
|
||||
setConfigId(cfg?.id ?? null);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAssoc) return;
|
||||
loadOwnerCount(selectedAssoc);
|
||||
loadSegment(selectedAssoc);
|
||||
}, [selectedAssoc]);
|
||||
|
||||
const loadOwnerCount = async (id: string) => {
|
||||
const { count } = await supabase.from("owners").select("id", { count: "exact", head: true })
|
||||
.eq("association_id", id).eq("status", "active").not("email", "is", null);
|
||||
setOwnerCount(count || 0);
|
||||
};
|
||||
|
||||
const loadSegment = async (id: string) => {
|
||||
const { data } = await supabase.from("hostinger_reach_segments").select("*").eq("association_id", id).maybeSingle();
|
||||
setSegment((data as SegmentRow) ?? null);
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
setTesting(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke("reach-connection", {
|
||||
body: apiToken.trim() ? { api_token: apiToken.trim() } : {},
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!data.success) throw new Error(data.error || "Connection failed");
|
||||
toast({ title: "Connected", description: `Reach reachable — ${data.segment_count} segment(s).` });
|
||||
} catch (e: any) {
|
||||
toast({ variant: "destructive", title: "Connection failed", description: e.message });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveToken = async () => {
|
||||
if (!apiToken.trim()) {
|
||||
toast({ variant: "destructive", title: "Missing token", description: "Paste your Hostinger Reach API token." });
|
||||
return;
|
||||
}
|
||||
setSavingToken(true);
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
const payload = { api_token: apiToken.trim(), created_by: user?.id };
|
||||
const { error } = configId
|
||||
? await supabase.from("hostinger_reach_config").update(payload).eq("id", configId)
|
||||
: await supabase.from("hostinger_reach_config").insert(payload);
|
||||
if (error) throw error;
|
||||
const { data: cfg } = await supabase
|
||||
.from("hostinger_reach_config").select("id").order("updated_at", { ascending: false }).limit(1).maybeSingle();
|
||||
setConfigId(cfg?.id ?? null);
|
||||
setApiToken("");
|
||||
toast({ title: "Saved", description: "Hostinger Reach API token stored." });
|
||||
} catch (e: any) {
|
||||
toast({ variant: "destructive", title: "Save failed", description: e.message });
|
||||
} finally {
|
||||
setSavingToken(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async () => {
|
||||
if (!configId) {
|
||||
toast({ variant: "destructive", title: "Not connected", description: "Save your Reach API token first." });
|
||||
return;
|
||||
}
|
||||
setSyncing(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke("reach-sync", { body: { association_id: selectedAssoc } });
|
||||
if (error) throw error;
|
||||
if (!data.success) throw new Error(data.error || "Sync failed");
|
||||
toast({
|
||||
title: "Sync complete",
|
||||
description: `${data.succeeded} synced${data.failed > 0 ? `, ${data.failed} failed` : ""} → segment "${data.segment_name}".`,
|
||||
});
|
||||
loadSegment(selectedAssoc);
|
||||
} catch (e: any) {
|
||||
toast({ variant: "destructive", title: "Sync failed", description: e.message });
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const assocName = associations.find((a) => a.id === selectedAssoc)?.name;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 max-w-4xl space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Mail className="w-7 h-7 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Hostinger Reach</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Push each association's owners into Hostinger Reach as contacts, organized into a per-association segment. Compose and send campaigns from the Reach dashboard.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2"><Plug className="w-5 h-5" /> API Connection</CardTitle>
|
||||
<CardDescription>
|
||||
One account-wide token. Create it in hPanel → Reach → API. It's stored securely and never shown again after saving.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{configId && (
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-700 border-green-500/30">
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" /> Connected
|
||||
</Badge>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label>Hostinger Reach API Token</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={apiToken}
|
||||
onChange={(e) => setApiToken(e.target.value)}
|
||||
placeholder={configId ? "•••••••• (saved) — paste a new token to replace" : "Paste your API token"}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button variant="outline" onClick={handleTest} disabled={testing}>
|
||||
{testing ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <RefreshCw className="w-4 h-4 mr-2" />}
|
||||
Test Connection
|
||||
</Button>
|
||||
<Button onClick={handleSaveToken} disabled={savingToken || !apiToken.trim()}>
|
||||
{savingToken ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
|
||||
{configId ? "Replace Token" : "Save Token"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sync */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Users className="w-5 h-5" /> Sync Owners to Reach</CardTitle>
|
||||
<CardDescription>Each association's active owners (with email) are pushed as contacts and grouped into their own Reach segment.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label>Association</Label>
|
||||
<Select value={selectedAssoc} onValueChange={setSelectedAssoc}>
|
||||
<SelectTrigger><SelectValue placeholder="Select association" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{associations.map((a) => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm">
|
||||
<span className="font-medium text-foreground">{ownerCount}</span> active owner(s) with email
|
||||
{segment?.segment_name && <> · segment: <span className="font-medium">{segment.segment_name}</span></>}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Re-running is safe — existing contacts aren't duplicated.</p>
|
||||
</div>
|
||||
<Button onClick={handleSync} disabled={syncing || !configId}>
|
||||
{syncing ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <RefreshCw className="w-4 h-4 mr-2" />}
|
||||
Sync Now
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{segment?.last_sync_at && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="text-muted-foreground">Last sync</p>
|
||||
<p>{format(new Date(segment.last_sync_at), "PPpp")}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{segment.last_sync_status === "success" && (
|
||||
<Badge className="bg-green-500/15 text-green-700 border-green-500/30" variant="outline">
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" /> Success
|
||||
</Badge>
|
||||
)}
|
||||
{segment.last_sync_status === "partial" && (
|
||||
<Badge className="bg-yellow-500/15 text-yellow-700 border-yellow-500/30" variant="outline">
|
||||
<AlertCircle className="w-3 h-3 mr-1" /> Partial
|
||||
</Badge>
|
||||
)}
|
||||
{segment.last_sync_status === "failed" && (
|
||||
<Badge variant="destructive"><AlertCircle className="w-3 h-3 mr-1" /> Failed</Badge>
|
||||
)}
|
||||
<span className="text-muted-foreground">{segment.last_sync_count ?? 0} synced</span>
|
||||
</div>
|
||||
{segment.last_sync_error && (
|
||||
<p className="text-xs text-destructive mt-2 break-all">{segment.last_sync_error}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Campaigns live in Reach */}
|
||||
<Card className="border-dashed">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-muted-foreground"><Send className="w-5 h-5" /> Send Campaigns in Reach</CardTitle>
|
||||
<CardDescription>
|
||||
Hostinger Reach campaigns are composed and sent from the Reach dashboard. Sync owners here, then target the
|
||||
{assocName ? <> <span className="font-medium text-foreground">{assocName}</span></> : " association"} segment in Reach.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<a href={REACH_DASHBOARD_URL} target="_blank" rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1 text-sm">
|
||||
Open Hostinger Reach <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,530 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Loader2, Mail, RefreshCw, CheckCircle2, AlertCircle, Plus, Send, ExternalLink } from "lucide-react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface Association { id: string; name: string; }
|
||||
interface MailchimpConfig {
|
||||
id: string;
|
||||
association_id: string;
|
||||
api_key: string;
|
||||
server_prefix: string;
|
||||
audience_id: string | null;
|
||||
audience_name: string | null;
|
||||
last_sync_at: string | null;
|
||||
last_sync_status: string | null;
|
||||
last_sync_count: number | null;
|
||||
last_sync_error: string | null;
|
||||
}
|
||||
interface Audience { id: string; name: string; stats?: { member_count?: number } }
|
||||
|
||||
export default function MailchimpPage() {
|
||||
const { toast } = useToast();
|
||||
const [associations, setAssociations] = useState<Association[]>([]);
|
||||
const [selectedAssoc, setSelectedAssoc] = useState<string>("");
|
||||
const [config, setConfig] = useState<MailchimpConfig | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [serverPrefix, setServerPrefix] = useState("");
|
||||
const [audiences, setAudiences] = useState<Audience[]>([]);
|
||||
const [selectedAudience, setSelectedAudience] = useState<string>("");
|
||||
const [newAudienceName, setNewAudienceName] = useState("");
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [ownerCount, setOwnerCount] = useState<number>(0);
|
||||
|
||||
// Campaign state
|
||||
const [campaignSubject, setCampaignSubject] = useState("");
|
||||
const [campaignFromName, setCampaignFromName] = useState("");
|
||||
const [campaignReplyTo, setCampaignReplyTo] = useState("");
|
||||
const [campaignHtml, setCampaignHtml] = useState("");
|
||||
const [sendingCampaign, setSendingCampaign] = useState(false);
|
||||
const [lastCampaign, setLastCampaign] = useState<{ web_id?: number; sent: boolean } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const { data } = await supabase.from("associations").select("id, name").eq("status", "active").order("name");
|
||||
setAssociations(data || []);
|
||||
if (data && data.length > 0) setSelectedAssoc(data[0].id);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAssoc) return;
|
||||
loadConfig(selectedAssoc);
|
||||
loadOwnerCount(selectedAssoc);
|
||||
}, [selectedAssoc]);
|
||||
|
||||
const loadOwnerCount = async (id: string) => {
|
||||
const { count } = await supabase.from("owners").select("id", { count: "exact", head: true })
|
||||
.eq("association_id", id).eq("status", "active").not("email", "is", null);
|
||||
setOwnerCount(count || 0);
|
||||
};
|
||||
|
||||
const loadConfig = async (id: string) => {
|
||||
setLoading(true);
|
||||
setConfig(null);
|
||||
setApiKey(""); setServerPrefix(""); setAudiences([]); setSelectedAudience(""); setShowCreate(false);
|
||||
try {
|
||||
const { data, error } = await supabase.from("mailchimp_configs").select("*").eq("association_id", id).maybeSingle();
|
||||
if (error) throw error;
|
||||
if (data) {
|
||||
setConfig(data as any);
|
||||
setApiKey(data.api_key);
|
||||
setServerPrefix(data.server_prefix);
|
||||
setSelectedAudience(data.audience_id || "");
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast({ variant: "destructive", title: "Load error", description: e.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const detectPrefix = (key: string) => {
|
||||
const parts = key.split("-");
|
||||
return parts.length > 1 ? parts[parts.length - 1] : "";
|
||||
};
|
||||
|
||||
const handleApiKeyChange = (val: string) => {
|
||||
setApiKey(val);
|
||||
if (!serverPrefix) {
|
||||
const detected = detectPrefix(val.trim());
|
||||
if (detected) setServerPrefix(detected);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestAndLoad = async () => {
|
||||
if (!apiKey || !serverPrefix) {
|
||||
toast({ variant: "destructive", title: "Missing info", description: "Enter API key and server prefix." });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke("mailchimp-audiences", {
|
||||
body: { action: "list", api_key: apiKey, server_prefix: serverPrefix },
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!data.success) throw new Error(data.error || "Failed to connect");
|
||||
setAudiences(data.lists || []);
|
||||
toast({ title: "Connected", description: `Found ${data.lists?.length || 0} audience(s).` });
|
||||
} catch (e: any) {
|
||||
toast({ variant: "destructive", title: "Mailchimp connection failed", description: e.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateAudience = async () => {
|
||||
const assoc = associations.find(a => a.id === selectedAssoc);
|
||||
const name = newAudienceName.trim() || assoc?.name || "HOA Owners";
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke("mailchimp-audiences", {
|
||||
body: {
|
||||
action: "create",
|
||||
api_key: apiKey,
|
||||
server_prefix: serverPrefix,
|
||||
audience_name: name,
|
||||
association_name: assoc?.name,
|
||||
},
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!data.success) throw new Error(data.error || "Create failed");
|
||||
toast({ title: "Audience created", description: data.list?.name });
|
||||
setSelectedAudience(data.list.id);
|
||||
await handleTestAndLoad();
|
||||
setShowCreate(false);
|
||||
setNewAudienceName("");
|
||||
} catch (e: any) {
|
||||
toast({ variant: "destructive", title: "Create failed", description: e.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedAssoc || !apiKey || !serverPrefix) {
|
||||
toast({ variant: "destructive", title: "Missing fields", description: "API key, server prefix and audience are required." });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const audience = audiences.find(a => a.id === selectedAudience);
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
const payload = {
|
||||
association_id: selectedAssoc,
|
||||
api_key: apiKey,
|
||||
server_prefix: serverPrefix,
|
||||
audience_id: selectedAudience || null,
|
||||
audience_name: audience?.name || null,
|
||||
created_by: user?.id,
|
||||
};
|
||||
const { error } = config
|
||||
? await supabase.from("mailchimp_configs").update(payload).eq("id", config.id)
|
||||
: await supabase.from("mailchimp_configs").insert(payload);
|
||||
if (error) throw error;
|
||||
toast({ title: "Saved", description: "Mailchimp configuration updated." });
|
||||
loadConfig(selectedAssoc);
|
||||
} catch (e: any) {
|
||||
toast({ variant: "destructive", title: "Save failed", description: e.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async () => {
|
||||
if (!config?.audience_id) {
|
||||
toast({ variant: "destructive", title: "Not configured", description: "Save Mailchimp config with an audience first." });
|
||||
return;
|
||||
}
|
||||
setSyncing(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke("mailchimp-sync", {
|
||||
body: { association_id: selectedAssoc },
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!data.success) throw new Error(data.error || "Sync failed");
|
||||
toast({
|
||||
title: "Sync complete",
|
||||
description: `${data.succeeded} synced${data.failed > 0 ? `, ${data.failed} failed` : ""}.`,
|
||||
});
|
||||
loadConfig(selectedAssoc);
|
||||
} catch (e: any) {
|
||||
toast({ variant: "destructive", title: "Sync failed", description: e.message });
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
if (!config) return;
|
||||
if (!confirm("Disconnect Mailchimp for this association? Owners already in Mailchimp won't be removed.")) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { error } = await supabase.from("mailchimp_configs").delete().eq("id", config.id);
|
||||
if (error) throw error;
|
||||
toast({ title: "Disconnected" });
|
||||
loadConfig(selectedAssoc);
|
||||
} catch (e: any) {
|
||||
toast({ variant: "destructive", title: "Failed", description: e.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendCampaign = async (sendNow: boolean) => {
|
||||
if (!campaignSubject || !campaignFromName || !campaignReplyTo || !campaignHtml) {
|
||||
toast({ variant: "destructive", title: "Missing fields", description: "Subject, from name, reply-to and content are required." });
|
||||
return;
|
||||
}
|
||||
setSendingCampaign(true);
|
||||
setLastCampaign(null);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke("mailchimp-campaign", {
|
||||
body: {
|
||||
association_id: selectedAssoc,
|
||||
subject: campaignSubject,
|
||||
from_name: campaignFromName,
|
||||
reply_to: campaignReplyTo,
|
||||
html: campaignHtml,
|
||||
send_now: sendNow,
|
||||
},
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!data.success) throw new Error(data.error || "Campaign failed");
|
||||
setLastCampaign({ web_id: data.web_id, sent: data.sent });
|
||||
toast({
|
||||
title: sendNow ? "Campaign sent!" : "Draft created",
|
||||
description: sendNow
|
||||
? "Mailchimp is delivering your campaign to the audience."
|
||||
: "Saved as a draft in Mailchimp — review and send from there.",
|
||||
});
|
||||
if (sendNow) {
|
||||
setCampaignSubject(""); setCampaignHtml("");
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast({ variant: "destructive", title: "Campaign failed", description: e.message });
|
||||
} finally {
|
||||
setSendingCampaign(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 max-w-4xl space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Mail className="w-7 h-7 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Mailchimp Integration</h1>
|
||||
<p className="text-sm text-muted-foreground">Sync owners from each association into a Mailchimp audience for marketing campaigns.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Select Association</CardTitle>
|
||||
<CardDescription>Each association uses its own Mailchimp account and audience.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Select value={selectedAssoc} onValueChange={setSelectedAssoc}>
|
||||
<SelectTrigger><SelectValue placeholder="Select association" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{associations.map(a => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedAssoc && (
|
||||
<p className="text-sm text-muted-foreground mt-3">
|
||||
<span className="font-medium text-foreground">{ownerCount}</span> active owner(s) with email in this association.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedAssoc && (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between">
|
||||
<div>
|
||||
<CardTitle>Mailchimp Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
Get your API key from Mailchimp → Account → Extras → API keys. The server prefix is the suffix after the dash (e.g. <code className="bg-muted px-1 rounded">us21</code>).
|
||||
</CardDescription>
|
||||
</div>
|
||||
{config && (
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-700 border-green-500/30">
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" /> Connected
|
||||
</Badge>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>API Key</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
placeholder="abc123...-us21"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Server Prefix</Label>
|
||||
<Input
|
||||
value={serverPrefix}
|
||||
onChange={(e) => setServerPrefix(e.target.value)}
|
||||
placeholder="us21"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button variant="outline" onClick={handleTestAndLoad} disabled={loading || !apiKey || !serverPrefix}>
|
||||
{loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <RefreshCw className="w-4 h-4 mr-2" />}
|
||||
Test & Load Audiences
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowCreate(!showCreate)} disabled={!apiKey || !serverPrefix}>
|
||||
<Plus className="w-4 h-4 mr-2" /> Create New Audience
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="p-4 border rounded-lg bg-muted/30 space-y-3">
|
||||
<Label>New audience name</Label>
|
||||
<Input
|
||||
value={newAudienceName}
|
||||
onChange={(e) => setNewAudienceName(e.target.value)}
|
||||
placeholder={associations.find(a => a.id === selectedAssoc)?.name || "HOA Owners"}
|
||||
/>
|
||||
<Button size="sm" onClick={handleCreateAudience} disabled={loading}>
|
||||
{loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
|
||||
Create in Mailchimp
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{audiences.length > 0 && (
|
||||
<div>
|
||||
<Label>Audience</Label>
|
||||
<Select value={selectedAudience} onValueChange={setSelectedAudience}>
|
||||
<SelectTrigger><SelectValue placeholder="Pick an audience" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{audiences.map(a => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name} {a.stats?.member_count != null && `(${a.stats.member_count} members)`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config?.audience_name && audiences.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Currently linked: <span className="font-medium text-foreground">{config.audience_name}</span> — click "Test & Load" to change.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button onClick={handleSave} disabled={loading}>
|
||||
{loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
|
||||
Save Configuration
|
||||
</Button>
|
||||
{config && (
|
||||
<Button variant="destructive" onClick={handleDisconnect} disabled={loading}>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{config?.audience_id && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sync Owners to Mailchimp</CardTitle>
|
||||
<CardDescription>
|
||||
Pushes all active owners with email to the linked audience. Existing members are updated; new members are added as subscribed.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm">
|
||||
Audience: <span className="font-medium">{config.audience_name}</span>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{ownerCount} owner(s) will be synced.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleSync} disabled={syncing}>
|
||||
{syncing ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <RefreshCw className="w-4 h-4 mr-2" />}
|
||||
Sync Now
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{config.last_sync_at && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="text-muted-foreground">Last sync</p>
|
||||
<p>{format(new Date(config.last_sync_at), "PPpp")}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{config.last_sync_status === "success" && (
|
||||
<Badge className="bg-green-500/15 text-green-700 border-green-500/30" variant="outline">
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" /> Success
|
||||
</Badge>
|
||||
)}
|
||||
{config.last_sync_status === "partial" && (
|
||||
<Badge className="bg-yellow-500/15 text-yellow-700 border-yellow-500/30" variant="outline">
|
||||
<AlertCircle className="w-3 h-3 mr-1" /> Partial
|
||||
</Badge>
|
||||
)}
|
||||
{config.last_sync_status === "failed" && (
|
||||
<Badge variant="destructive">
|
||||
<AlertCircle className="w-3 h-3 mr-1" /> Failed
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-muted-foreground">{config.last_sync_count} synced</span>
|
||||
</div>
|
||||
{config.last_sync_error && (
|
||||
<p className="text-xs text-destructive mt-2 break-all">{config.last_sync_error}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{selectedAssoc && !config?.audience_id && (
|
||||
<Card className="border-dashed">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-muted-foreground"><Send className="w-5 h-5" /> Send Campaign via Mailchimp</CardTitle>
|
||||
<CardDescription>
|
||||
Save your Mailchimp credentials and select an audience above to unlock the campaign sender.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{config?.audience_id && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Send className="w-5 h-5" /> Send Campaign via Mailchimp</CardTitle>
|
||||
<CardDescription>
|
||||
Compose and send an email campaign directly through Mailchimp to <span className="font-medium text-foreground">{config.audience_name}</span>. You can send immediately or save as a draft to review in Mailchimp.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Subject Line</Label>
|
||||
<Input value={campaignSubject} onChange={(e) => setCampaignSubject(e.target.value)} placeholder="Important community update" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>From Name</Label>
|
||||
<Input value={campaignFromName} onChange={(e) => setCampaignFromName(e.target.value)} placeholder={associations.find(a => a.id === selectedAssoc)?.name || "HOA Board"} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Reply-To Email</Label>
|
||||
<Input type="email" value={campaignReplyTo} onChange={(e) => setCampaignReplyTo(e.target.value)} placeholder="board@yourhoa.com" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Email Content (HTML)</Label>
|
||||
<Textarea
|
||||
value={campaignHtml}
|
||||
onChange={(e) => setCampaignHtml(e.target.value)}
|
||||
placeholder="<h1>Hello owners</h1><p>Your message here...</p>"
|
||||
rows={10}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Plain HTML supported. Mailchimp automatically appends required unsubscribe footers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<Button onClick={() => handleSendCampaign(true)} disabled={sendingCampaign}>
|
||||
{sendingCampaign ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Send className="w-4 h-4 mr-2" />}
|
||||
Send Now to {ownerCount} Recipients
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => handleSendCampaign(false)} disabled={sendingCampaign}>
|
||||
Save as Draft in Mailchimp
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{lastCampaign && (
|
||||
<div className="p-3 rounded-md bg-muted/40 border text-sm flex items-center justify-between gap-2">
|
||||
<span>
|
||||
{lastCampaign.sent ? "✓ Campaign sent successfully" : "✓ Draft saved in Mailchimp"}
|
||||
</span>
|
||||
{lastCampaign.web_id && (
|
||||
<a
|
||||
href={`https://${config.server_prefix}.admin.mailchimp.com/campaigns/edit?id=${lastCampaign.web_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Open in Mailchimp <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
.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,
|
||||
comment: seed.trim(),
|
||||
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