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:
2026-06-11 23:07:30 -04:00
parent 220892203c
commit abd46bcb2b
15 changed files with 1041 additions and 726 deletions
+5 -20
View File
@@ -26,23 +26,8 @@ jobs:
- name: Build - name: Build
run: bun run build run: bun run build
deploy: # Deployment: the VPS (avria.cloud) polls this repo every 2 minutes via
# Auto-deploy to the VPS (avria.cloud) on every push to main. # /home/avria/auto-deploy.sh (cron, user avria) and runs deploy.sh when main
# The SSH key is restricted on the server (forced command): it can only # moves — pull/build/rsync to public_html. CI here is a build check only;
# run /home/avria/deploy.sh, which pulls main, builds, and rsyncs # pushing SSH from Actions doesn't work because Hostinger's edge firewall
# dist/ -> public_html. The command string below is therefore ignored # blocks the runners' IPs.
# 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 }}
+2 -2
View File
@@ -123,7 +123,7 @@ import EmailSendersPage from "./pages/EmailSendersPage";
import EmailTemplatesPage from "./pages/EmailTemplatesPage"; import EmailTemplatesPage from "./pages/EmailTemplatesPage";
import NotifyBoardPage from "./pages/NotifyBoardPage"; import NotifyBoardPage from "./pages/NotifyBoardPage";
import NotifyOwnersPage from "./pages/NotifyOwnersPage"; import NotifyOwnersPage from "./pages/NotifyOwnersPage";
import MailchimpPage from "./pages/MailchimpPage"; import HostingerReachPage from "./pages/HostingerReachPage";
import DataMigration from "./pages/DataMigration"; import DataMigration from "./pages/DataMigration";
import MediaLibraryPage from "./pages/MediaLibraryPage"; import MediaLibraryPage from "./pages/MediaLibraryPage";
import MigrationFieldsPage from "./pages/MigrationFieldsPage"; import MigrationFieldsPage from "./pages/MigrationFieldsPage";
@@ -419,7 +419,7 @@ const App = () => (
<Route path="email-templates" element={<EmailTemplatesPage />} /> <Route path="email-templates" element={<EmailTemplatesPage />} />
<Route path="notify-board" element={<NotifyBoardPage />} /> <Route path="notify-board" element={<NotifyBoardPage />} />
<Route path="notify-owners" element={<NotifyOwnersPage />} /> <Route path="notify-owners" element={<NotifyOwnersPage />} />
<Route path="mailchimp" element={<MailchimpPage />} /> <Route path="hostinger-reach" element={<HostingerReachPage />} />
<Route path="messages" element={<MessagesPage />} /> <Route path="messages" element={<MessagesPage />} />
{/* Financial module */} {/* Financial module */}
<Route path="vendors" element={<VendorsPage />} /> <Route path="vendors" element={<VendorsPage />} />
+1 -1
View File
@@ -50,7 +50,7 @@ export const SETTINGS_PAGES = [
items: [ items: [
{ path: "/dashboard/settings/buildium", title: "Buildium", icon: Building2 }, { path: "/dashboard/settings/buildium", title: "Buildium", icon: Building2 },
{ path: "/dashboard/settings/stripe-accounts", title: "Payment Gateways", icon: CreditCard }, { 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 },
], ],
}, },
]; ];
+345 -14
View File
@@ -14,6 +14,51 @@ export type Database = {
} }
public: { public: {
Tables: { 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: { address_geocodes: {
Row: { Row: {
address: string address: string
@@ -133,6 +178,7 @@ export type Database = {
show_on_public_page: boolean show_on_public_page: boolean
signable_documents: Json signable_documents: Json
sort_order: number | null sort_order: number | null
source_rv_lot_id: string | null
updated_at: string updated_at: string
visible_to_roles: Json visible_to_roles: Json
} }
@@ -152,6 +198,7 @@ export type Database = {
show_on_public_page?: boolean show_on_public_page?: boolean
signable_documents?: Json signable_documents?: Json
sort_order?: number | null sort_order?: number | null
source_rv_lot_id?: string | null
updated_at?: string updated_at?: string
visible_to_roles?: Json visible_to_roles?: Json
} }
@@ -171,6 +218,7 @@ export type Database = {
show_on_public_page?: boolean show_on_public_page?: boolean
signable_documents?: Json signable_documents?: Json
sort_order?: number | null sort_order?: number | null
source_rv_lot_id?: string | null
updated_at?: string updated_at?: string
visible_to_roles?: Json 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: { association_custom_fields: {
Row: { Row: {
association_id: string association_id: string
@@ -1105,6 +1171,7 @@ export type Database = {
} }
associations: { associations: {
Row: { Row: {
accounting_system: string
address: string | null address: string | null
attorney_email: string | null attorney_email: string | null
attorney_firm: string | null attorney_firm: string | null
@@ -1137,6 +1204,7 @@ export type Database = {
zoho_organization_id: string | null zoho_organization_id: string | null
} }
Insert: { Insert: {
accounting_system?: string
address?: string | null address?: string | null
attorney_email?: string | null attorney_email?: string | null
attorney_firm?: string | null attorney_firm?: string | null
@@ -1169,6 +1237,7 @@ export type Database = {
zoho_organization_id?: string | null zoho_organization_id?: string | null
} }
Update: { Update: {
accounting_system?: string
address?: string | null address?: string | null
attorney_email?: string | null attorney_email?: string | null
attorney_firm?: string | null attorney_firm?: string | null
@@ -1555,6 +1624,8 @@ export type Database = {
created_at: string created_at: string
created_by: string | null created_by: string | null
description: string | null description: string | null
document_name: string | null
document_url: string | null
expiry_date: string | null expiry_date: string | null
id: string id: string
project_id: string | null project_id: string | null
@@ -1569,6 +1640,8 @@ export type Database = {
created_at?: string created_at?: string
created_by?: string | null created_by?: string | null
description?: string | null description?: string | null
document_name?: string | null
document_url?: string | null
expiry_date?: string | null expiry_date?: string | null
id?: string id?: string
project_id?: string | null project_id?: string | null
@@ -1583,6 +1656,8 @@ export type Database = {
created_at?: string created_at?: string
created_by?: string | null created_by?: string | null
description?: string | null description?: string | null
document_name?: string | null
document_url?: string | null
expiry_date?: string | null expiry_date?: string | null
id?: string id?: string
project_id?: string | null project_id?: string | null
@@ -2133,6 +2208,7 @@ export type Database = {
Row: { Row: {
approval_authority: boolean approval_authority: boolean
association_id: string association_id: string
can_upload: boolean
created_at: string created_at: string
id: string id: string
member_email: string | null member_email: string | null
@@ -2145,6 +2221,7 @@ export type Database = {
Insert: { Insert: {
approval_authority?: boolean approval_authority?: boolean
association_id: string association_id: string
can_upload?: boolean
created_at?: string created_at?: string
id?: string id?: string
member_email?: string | null member_email?: string | null
@@ -2157,6 +2234,7 @@ export type Database = {
Update: { Update: {
approval_authority?: boolean approval_authority?: boolean
association_id?: string association_id?: string
can_upload?: boolean
created_at?: string created_at?: string
id?: string id?: string
member_email?: string | null member_email?: string | null
@@ -2803,6 +2881,8 @@ export type Database = {
id: string id: string
logo_url: string | null logo_url: string | null
memo_prefix: string | null memo_prefix: string | null
micr_gap_1: number
micr_gap_2: number
offset_x: number offset_x: number
offset_y: number offset_y: number
payer_address: string | null payer_address: string | null
@@ -2825,6 +2905,8 @@ export type Database = {
id?: string id?: string
logo_url?: string | null logo_url?: string | null
memo_prefix?: string | null memo_prefix?: string | null
micr_gap_1?: number
micr_gap_2?: number
offset_x?: number offset_x?: number
offset_y?: number offset_y?: number
payer_address?: string | null payer_address?: string | null
@@ -2847,6 +2929,8 @@ export type Database = {
id?: string id?: string
logo_url?: string | null logo_url?: string | null
memo_prefix?: string | null memo_prefix?: string | null
micr_gap_1?: number
micr_gap_2?: number
offset_x?: number offset_x?: number
offset_y?: number offset_y?: number
payer_address?: string | null payer_address?: string | null
@@ -3451,6 +3535,8 @@ export type Database = {
id: string id: string
logo_url: string | null logo_url: string | null
memo_prefix: string | null memo_prefix: string | null
micr_gap_1: number
micr_gap_2: number
offset_x: number offset_x: number
offset_y: number offset_y: number
payer_address: string | null payer_address: string | null
@@ -3472,6 +3558,8 @@ export type Database = {
id?: string id?: string
logo_url?: string | null logo_url?: string | null
memo_prefix?: string | null memo_prefix?: string | null
micr_gap_1?: number
micr_gap_2?: number
offset_x?: number offset_x?: number
offset_y?: number offset_y?: number
payer_address?: string | null payer_address?: string | null
@@ -3493,6 +3581,8 @@ export type Database = {
id?: string id?: string
logo_url?: string | null logo_url?: string | null
memo_prefix?: string | null memo_prefix?: string | null
micr_gap_1?: number
micr_gap_2?: number
offset_x?: number offset_x?: number
offset_y?: number offset_y?: number
payer_address?: string | null payer_address?: string | null
@@ -4352,12 +4442,18 @@ export type Database = {
email_headers: Json | null email_headers: Json | null
feature_type: string | null feature_type: string | null
id: string 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 proof_url: string | null
recipient_email: string | null recipient_email: string | null
sender_email: string | null sender_email: string | null
sent_at: string | null sent_at: string | null
status: string | null status: string | null
subject: string | null subject: string | null
tracking_id: string | null
user_id: string user_id: string
} }
Insert: { Insert: {
@@ -4366,12 +4462,18 @@ export type Database = {
email_headers?: Json | null email_headers?: Json | null
feature_type?: string | null feature_type?: string | null
id?: string 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 proof_url?: string | null
recipient_email?: string | null recipient_email?: string | null
sender_email?: string | null sender_email?: string | null
sent_at?: string | null sent_at?: string | null
status?: string | null status?: string | null
subject?: string | null subject?: string | null
tracking_id?: string | null
user_id: string user_id: string
} }
Update: { Update: {
@@ -4380,12 +4482,18 @@ export type Database = {
email_headers?: Json | null email_headers?: Json | null
feature_type?: string | null feature_type?: string | null
id?: string 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 proof_url?: string | null
recipient_email?: string | null recipient_email?: string | null
sender_email?: string | null sender_email?: string | null
sent_at?: string | null sent_at?: string | null
status?: string | null status?: string | null
subject?: string | null subject?: string | null
tracking_id?: string | null
user_id?: string user_id?: string
} }
Relationships: [] 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: { in_app_notifications: {
Row: { Row: {
created_at: string created_at: string
@@ -7173,12 +7352,42 @@ export type Database = {
} }
Relationships: [] 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: { rv_boat_lot_rentals: {
Row: { Row: {
association_id: string association_id: string
created_at: string created_at: string
end_date: string | null end_date: string | null
id: string id: string
insurance_carrier: string | null
insurance_document_url: string | null
insurance_expiration_date: string | null
insurance_policy_number: string | null
is_owner: boolean is_owner: boolean
lot_id: string lot_id: string
monthly_rate: number | null monthly_rate: number | null
@@ -7199,6 +7408,10 @@ export type Database = {
created_at?: string created_at?: string
end_date?: string | null end_date?: string | null
id?: string id?: string
insurance_carrier?: string | null
insurance_document_url?: string | null
insurance_expiration_date?: string | null
insurance_policy_number?: string | null
is_owner?: boolean is_owner?: boolean
lot_id: string lot_id: string
monthly_rate?: number | null monthly_rate?: number | null
@@ -7219,6 +7432,10 @@ export type Database = {
created_at?: string created_at?: string
end_date?: string | null end_date?: string | null
id?: string id?: string
insurance_carrier?: string | null
insurance_document_url?: string | null
insurance_expiration_date?: string | null
insurance_policy_number?: string | null
is_owner?: boolean is_owner?: boolean
lot_id?: string lot_id?: string
monthly_rate?: number | null monthly_rate?: number | null
@@ -7278,6 +7495,7 @@ export type Database = {
owner_id: string | null owner_id: string | null
position: number position: number
requested_lot_type: string | null requested_lot_type: string | null
requested_size: string | null
requester_email: string | null requester_email: string | null
requester_name: string requester_name: string
requester_phone: string | null requester_phone: string | null
@@ -7300,6 +7518,7 @@ export type Database = {
owner_id?: string | null owner_id?: string | null
position: number position: number
requested_lot_type?: string | null requested_lot_type?: string | null
requested_size?: string | null
requester_email?: string | null requester_email?: string | null
requester_name: string requester_name: string
requester_phone?: string | null requester_phone?: string | null
@@ -7322,6 +7541,7 @@ export type Database = {
owner_id?: string | null owner_id?: string | null
position?: number position?: number
requested_lot_type?: string | null requested_lot_type?: string | null
requested_size?: string | null
requester_email?: string | null requester_email?: string | null
requester_name?: string requester_name?: string
requester_phone?: string | null 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: { saved_form_templates: {
Row: { Row: {
association_id: string | null association_id: string | null
@@ -9001,6 +9265,7 @@ export type Database = {
association_id: string association_id: string
bathrooms: number | null bathrooms: number | null
bedrooms: number | null bedrooms: number | null
buildium_account_number: string | null
buildium_unit_id: string | null buildium_unit_id: string | null
city: string | null city: string | null
created_at: string created_at: string
@@ -9028,6 +9293,7 @@ export type Database = {
association_id: string association_id: string
bathrooms?: number | null bathrooms?: number | null
bedrooms?: number | null bedrooms?: number | null
buildium_account_number?: string | null
buildium_unit_id?: string | null buildium_unit_id?: string | null
city?: string | null city?: string | null
created_at?: string created_at?: string
@@ -9055,6 +9321,7 @@ export type Database = {
association_id?: string association_id?: string
bathrooms?: number | null bathrooms?: number | null
bedrooms?: number | null bedrooms?: number | null
buildium_account_number?: string | null
buildium_unit_id?: string | null buildium_unit_id?: string | null
city?: string | null city?: string | null
created_at?: string created_at?: string
@@ -9125,7 +9392,7 @@ export type Database = {
} }
Insert: { Insert: {
id?: string id?: string
role: Database["public"]["Enums"]["app_role"] role?: Database["public"]["Enums"]["app_role"]
user_id: string user_id: string
} }
Update: { Update: {
@@ -9356,6 +9623,7 @@ export type Database = {
phone: string | null phone: string | null
profile_last_submitted_at: string | null profile_last_submitted_at: string | null
remittance_address: string | null remittance_address: string | null
share_with_board: boolean
tax_id: string | null tax_id: string | null
updated_at: string updated_at: string
w9_document_url: string | null w9_document_url: string | null
@@ -9390,6 +9658,7 @@ export type Database = {
phone?: string | null phone?: string | null
profile_last_submitted_at?: string | null profile_last_submitted_at?: string | null
remittance_address?: string | null remittance_address?: string | null
share_with_board?: boolean
tax_id?: string | null tax_id?: string | null
updated_at?: string updated_at?: string
w9_document_url?: string | null w9_document_url?: string | null
@@ -9424,6 +9693,7 @@ export type Database = {
phone?: string | null phone?: string | null
profile_last_submitted_at?: string | null profile_last_submitted_at?: string | null
remittance_address?: string | null remittance_address?: string | null
share_with_board?: boolean
tax_id?: string | null tax_id?: string | null
updated_at?: string updated_at?: string
w9_document_url?: string | null w9_document_url?: string | null
@@ -9503,9 +9773,12 @@ export type Database = {
category: string category: string
citation: string | null citation: string | null
created_at: string | null created_at: string | null
cure_days: number | null
description: string | null description: string | null
id: string id: string
letter_templates: string | null
requested_action: string | null requested_action: string | null
stage_count: number | null
updated_at: string | null updated_at: string | null
} }
Insert: { Insert: {
@@ -9514,9 +9787,12 @@ export type Database = {
category: string category: string
citation?: string | null citation?: string | null
created_at?: string | null created_at?: string | null
cure_days?: number | null
description?: string | null description?: string | null
id?: string id?: string
letter_templates?: string | null
requested_action?: string | null requested_action?: string | null
stage_count?: number | null
updated_at?: string | null updated_at?: string | null
} }
Update: { Update: {
@@ -9525,9 +9801,12 @@ export type Database = {
category?: string category?: string
citation?: string | null citation?: string | null
created_at?: string | null created_at?: string | null
cure_days?: number | null
description?: string | null description?: string | null
id?: string id?: string
letter_templates?: string | null
requested_action?: string | null requested_action?: string | null
stage_count?: number | null
updated_at?: string | null updated_at?: string | null
} }
Relationships: [ Relationships: [
@@ -9546,6 +9825,7 @@ export type Database = {
assigned_to: string | null assigned_to: string | null
association_id: string association_id: string
category: string | null category: string | null
certified_mail: string | null
created_at: string created_at: string
created_by: string | null created_by: string | null
description: string | null description: string | null
@@ -9575,6 +9855,7 @@ export type Database = {
assigned_to?: string | null assigned_to?: string | null
association_id: string association_id: string
category?: string | null category?: string | null
certified_mail?: string | null
created_at?: string created_at?: string
created_by?: string | null created_by?: string | null
description?: string | null description?: string | null
@@ -9604,6 +9885,7 @@ export type Database = {
assigned_to?: string | null assigned_to?: string | null
association_id?: string association_id?: string
category?: string | null category?: string | null
certified_mail?: string | null
created_at?: string created_at?: string
created_by?: string | null created_by?: string | null
description?: string | null description?: string | null
@@ -9982,6 +10264,8 @@ export type Database = {
} }
Functions: { Functions: {
_get_vendor_ach_key: { Args: never; Returns: string } _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: { calculate_legal_matter_ledger_amount: {
Args: { p_legal_matter_id: string } Args: { p_legal_matter_id: string }
Returns: number Returns: number
@@ -10016,6 +10300,10 @@ export type Database = {
Args: { payload: Json; queue_name: string } Args: { payload: Json; queue_name: string }
Returns: number Returns: number
} }
ensure_accounting_vendor: {
Args: { _association_id: string; _public_vendor_id: string }
Returns: string
}
get_association_accounting_system: { get_association_accounting_system: {
Args: { _association_id: string } Args: { _association_id: string }
Returns: string Returns: string
@@ -10037,7 +10325,6 @@ export type Database = {
Args: { _user_id?: string } Args: { _user_id?: string }
Returns: string[] Returns: string[]
} }
get_maintenance_status: { Args: never; Returns: Json }
get_master_board_association_ids: { get_master_board_association_ids: {
Args: { _user_id?: string } Args: { _user_id?: string }
Returns: string[] Returns: string[]
@@ -10149,6 +10436,7 @@ export type Database = {
} }
Returns: boolean Returns: boolean
} }
is_management_staff: { Args: { _user_id?: string }; Returns: boolean }
is_master_board_member_of_association: { is_master_board_member_of_association: {
Args: { _association_id: string; _user_id: string } Args: { _association_id: string; _user_id: string }
Returns: boolean Returns: boolean
@@ -10193,6 +10481,16 @@ export type Database = {
}[] }[]
} }
lookup_email_by_username: { Args: { _username: string }; Returns: string } 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: { lookup_signup_code: {
Args: { p_code: string } Args: { p_code: string }
Returns: { Returns: {
@@ -10305,6 +10603,13 @@ export type Database = {
} }
Returns: number 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: { read_email_batch: {
Args: { batch_size: number; queue_name: string; vt: number } Args: { batch_size: number; queue_name: string; vt: number }
Returns: { Returns: {
@@ -10322,10 +10627,30 @@ export type Database = {
Args: { p_option: string; p_token: string } Args: { p_option: string; p_token: string }
Returns: Json 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: { refresh_committee_member_active_status: {
Args: never Args: never
Returns: undefined 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: { submit_tenant_info: {
Args: { Args: {
p_email?: string p_email?: string
@@ -10393,18 +10718,21 @@ export type Database = {
| "admin" | "admin"
| "manager" | "manager"
| "homeowner" | "homeowner"
| "staff"
| "employee"
| "board_member" | "board_member"
| "arc_member" | "arc_member"
| "fining_member" | "association_management"
| "legal" | "legal"
| "rv_boat_lot"
| "fining_member"
| "management"
| "staff"
| "employee"
| "master_board_member" | "master_board_member"
| "rv_renter" | "rv_renter"
| "rv_owner" | "rv_owner"
| "rv_boat_lot" budget_entry_type: "account" | "customer_project"
| "management" budget_period_type: "monthly" | "quarterly" | "annually"
| "association_management" budget_status: "draft" | "active"
fee_exclusion_mode: "waive" | "override_amount" | "override_percent" fee_exclusion_mode: "waive" | "override_amount" | "override_percent"
fee_exclusion_type: "late_fee" | "interest" fee_exclusion_type: "late_fee" | "interest"
} }
@@ -10538,19 +10866,22 @@ export const Constants = {
"admin", "admin",
"manager", "manager",
"homeowner", "homeowner",
"staff",
"employee",
"board_member", "board_member",
"arc_member", "arc_member",
"fining_member", "association_management",
"legal", "legal",
"rv_boat_lot",
"fining_member",
"management",
"staff",
"employee",
"master_board_member", "master_board_member",
"rv_renter", "rv_renter",
"rv_owner", "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_mode: ["waive", "override_amount", "override_percent"],
fee_exclusion_type: ["late_fee", "interest"], fee_exclusion_type: ["late_fee", "interest"],
}, },
+57 -1
View File
@@ -19,8 +19,12 @@ import { useDropzone } from "react-dropzone";
import { import {
Plus, Search, Gavel, Filter, FileText, Calendar, Building2, User, Plus, Search, Gavel, Filter, FileText, Calendar, Building2, User,
Home, Clock, ChevronRight, CheckCircle2, XCircle, AlertCircle, 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"; } 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 type { Tables } from "@/integrations/supabase/types";
import { format } from "date-fns"; import { format } from "date-fns";
import VotingAndComments from "@/components/shared/VotingAndComments"; 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) => const ownerName = (app: AppRow) =>
app.owners ? `${app.owners.first_name} ${app.owners.last_name}` : "—"; app.owners ? `${app.owners.first_name} ${app.owners.last_name}` : "—";
@@ -778,6 +805,7 @@ export default function ARCApplicationsPage({ boardAssociationIds }: { boardAsso
<ARCDetailPanel <ARCDetailPanel
app={selectedApp} app={selectedApp}
onStatusChange={handleStatusChange} onStatusChange={handleStatusChange}
onDelete={handleDelete}
onClose={() => setSelectedApp(null)} onClose={() => setSelectedApp(null)}
onRefresh={fetchData} onRefresh={fetchData}
/> />
@@ -795,11 +823,13 @@ export default function ARCApplicationsPage({ boardAssociationIds }: { boardAsso
function ARCDetailPanel({ function ARCDetailPanel({
app, app,
onStatusChange, onStatusChange,
onDelete,
onClose, onClose,
onRefresh, onRefresh,
}: { }: {
app: AppRow; app: AppRow;
onStatusChange: (id: string, status: string, notes?: string) => void; onStatusChange: (id: string, status: string, notes?: string) => void;
onDelete: (app: AppRow) => void;
onClose: () => void; onClose: () => void;
onRefresh: () => void; onRefresh: () => void;
}) { }) {
@@ -924,6 +954,32 @@ function ARCDetailPanel({
> >
<Download className="h-3 w-3" /> Export Record <Download className="h-3 w-3" /> Export Record
</Button> </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"> <span className="text-[11px] text-muted-foreground">
{app.submitted_date ? format(new Date(app.submitted_date), "MMMM d, yyyy") : "—"} {app.submitted_date ? format(new Date(app.submitted_date), "MMMM d, yyyy") : "—"}
</span> </span>
+15 -126
View File
@@ -41,10 +41,6 @@ export default function ComposeEmailPage() {
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [tab, setTab] = useState("content"); const [tab, setTab] = useState("content");
const [pickerOpen, setPickerOpen] = useState<null | "to" | "cc" | "bcc">(null); 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 [signatures, setSignatures] = useState<EmailSignature[]>([]);
const [signatureId, setSignatureId] = useState<string>("none"); const [signatureId, setSignatureId] = useState<string>("none");
const [signatureHtml, setSignatureHtml] = useState<string>(""); const [signatureHtml, setSignatureHtml] = useState<string>("");
@@ -87,23 +83,6 @@ export default function ComposeEmailPage() {
if (name) setSubject(`Message for ${name}`); if (name) setSubject(`Message for ${name}`);
}, [searchParams]); }, [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 fetchSenders = async () => {
const { data: result } = await supabase.functions.invoke("send-smtp-email", { const { data: result } = await supabase.functions.invoke("send-smtp-email", {
body: { action: "list_senders" }, body: { action: "list_senders" },
@@ -170,53 +149,7 @@ export default function ComposeEmailPage() {
if (e.target.files) setAttachments(Array.from(e.target.files)); 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 () => { const handleSend = async () => {
if (sendMethod === "mailchimp") {
return handleSendMailchimp(true);
}
if (!senderId || !recipients.trim() || !subject.trim()) { if (!senderId || !recipients.trim() || !subject.trim()) {
toast({ variant: "destructive", title: "Validation Error", description: "Sender, recipients, and subject are required." }); toast({ variant: "destructive", title: "Validation Error", description: "Sender, recipients, and subject are required." });
return; return;
@@ -331,58 +264,20 @@ export default function ComposeEmailPage() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
<Card> <Card>
<CardHeader><CardTitle>Send Method</CardTitle></CardHeader> <CardHeader><CardTitle>Sender & Recipients</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>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{sendMethod === "smtp" ? ( <div className="space-y-2">
<div className="space-y-2"> <Label>From <span className="text-destructive">*</span></Label>
<Label>From <span className="text-destructive">*</span></Label> <Select value={senderId} onValueChange={setSenderId}>
<Select value={senderId} onValueChange={setSenderId}> <SelectTrigger><SelectValue placeholder="Select Sender" /></SelectTrigger>
<SelectTrigger><SelectValue placeholder="Select Sender" /></SelectTrigger> <SelectContent>
<SelectContent> {senders.map((s: any) => (
{senders.map((s: any) => ( <SelectItem key={s.id} value={s.id}>{s.sender_name} ({s.email_address})</SelectItem>
<SelectItem key={s.id} value={s.id}>{s.sender_name} ({s.email_address})</SelectItem> ))}
))} </SelectContent>
</SelectContent> </Select>
</Select> </div>
</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="space-y-2">
<div className="flex items-center justify-between flex-wrap gap-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"> <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> <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}> <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 ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Send className="h-4 w-4 mr-2" />}
{sending || uploading ? "Sending..." : (sendMethod === "mailchimp" ? "Send via Mailchimp" : "Send Email")} {sending || uploading ? "Sending..." : "Send Email"}
</Button> </Button>
</div> </div>
+266
View File
@@ -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>
);
}
-530
View File
@@ -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
View File
@@ -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 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 buildiumAssocId: string | null = p._arc_buildium_association_id || null;
const buildiumArcId: string | null = p.buildium_arc_request_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 deciderName: string | null = p._arc_decider_name || null;
const deciderDate: string | null = p._arc_decider_date || null;
const clean = stripPrivate(p); const clean = stripPrivate(p);
@@ -282,27 +279,32 @@ Deno.serve(async (req) => {
appId = ins.id; appId = ins.id;
} }
// Seed a system comment with the Buildium decision (since comments/voters aren't exposed via API) // Buildium's API exposes no comment threads or per-member votes — only the final decision
if (appId && (decisionNotes || deciderName)) { // and who recorded it. Surface that decision as a recorded vote in the Committee Review
const { data: existingComment } = await supabase // (entity_votes is what the ARC review UI reads). The decision text itself already lives in
.from("arc_application_comments") // arc_applications.decision_notes. Idempotent: re-syncing replaces the prior Buildium vote.
.select("id") const decisionRaw: string = String(p._arc_decision || "").toLowerCase();
.eq("application_id", appId) 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) .is("user_id", null)
.ilike("comment", "%[Imported from Buildium]%") .ilike("voter_name", "% (Buildium)");
.maybeSingle(); const { error: voteErr } = await supabase.from("entity_votes").insert({
if (!existingComment) { entity_type: "arc_application",
const seed = entity_id: appId,
`[Imported from Buildium]\n` + vote: voteDir,
(deciderName ? `Decision by: ${deciderName}${deciderDate ? ` on ${deciderDate}` : ""}\n` : "") + user_id: null,
(reviewDate && !deciderDate ? `Decision date: ${reviewDate}\n` : "") + voter_name: voterName,
(decisionNotes ? `Decision notes: ${decisionNotes}` : ""); recorded_by: null,
await supabase.from("arc_application_comments").insert({ });
application_id: appId, if (voteErr) console.warn(`ARC vote record failed for ${appId}: ${voteErr.message}`);
user_id: null,
comment: seed.trim(),
});
}
} }
// Download attached files from Buildium and upload into the arc-files bucket // 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>(); const ownerByBuildiumLocal = new Map<string, any>();
for (const o of ownersAll || []) ownerByBuildiumLocal.set(String(o.buildium_owner_id), o); 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) { for (const ba of buildiumAssocs) {
const assocLocalId = bAssocIdToLocalId.get(String(ba.Id)); const assocLocalId = bAssocIdToLocalId.get(String(ba.Id));
if (!assocLocalId || !isSelected(assocLocalId)) continue; if (!assocLocalId || !isSelected(assocLocalId)) continue;
@@ -462,10 +477,30 @@ Deno.serve(async (req) => {
for (const r of arcRequests) { for (const r of arcRequests) {
const buildiumArcId = String(r.Id); const buildiumArcId = String(r.Id);
// Buildium ARC payload doesn't expose UnitId — resolve unit via the owner's unit later. // Buildium ARC requests expose only OwnershipAccountId. Bridge it to the unit + owner
const buildiumUnitId = normId(r.UnitId); // via the ownership-accounts map built above.
const buildiumOwnerId = normId(r.OwnershipAccountId || r.AssociationOwnerId); const ownershipAccountId = normId(r.OwnershipAccountId);
const localOwner = buildiumOwnerId ? ownerByBuildiumLocal.get(buildiumOwnerId) : null; 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 const localUnit = buildiumUnitId
? unitByBuildiumId.get(buildiumUnitId) ? unitByBuildiumId.get(buildiumUnitId)
: (localOwner?.unit_id ? { id: localOwner.unit_id } : null); : (localOwner?.unit_id ? { id: localOwner.unit_id } : null);
@@ -478,9 +513,12 @@ Deno.serve(async (req) => {
const submittedDate = r.SubmittedDateTime const submittedDate = r.SubmittedDateTime
? String(r.SubmittedDateTime).split("T")[0] ? String(r.SubmittedDateTime).split("T")[0]
: (r.SubmittedDate ? String(r.SubmittedDate).split("T")[0] : null); : (r.SubmittedDate ? String(r.SubmittedDate).split("T")[0] : null);
const decisionDate = r.DecisionDateTime // Buildium's ARC resource has no decision-date field — the decision is recorded via the
? String(r.DecisionDateTime).split("T")[0] // last update, so use LastUpdatedDateTime as the review/decision date for finalized requests.
: (r.DecisionDate ? String(r.DecisionDate).split("T")[0] : null); 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 const decisionNotes = r.DecisionDescription
? String(r.DecisionDescription) ? String(r.DecisionDescription)
: (r.Decision && !/pending/i.test(String(r.Decision)) ? `Decision: ${r.Decision}` : null); : (r.Decision && !/pending/i.test(String(r.Decision)) ? `Decision: ${r.Decision}` : null);
@@ -527,21 +565,27 @@ Deno.serve(async (req) => {
decision_notes: decisionNotes, decision_notes: decisionNotes,
buildium_arc_request_id: buildiumArcId, buildium_arc_request_id: buildiumArcId,
_resolve_unit_buildium_id: buildiumUnitId, _resolve_unit_buildium_id: buildiumUnitId,
_resolve_owner_buildium_id: buildiumOwnerId, _resolve_owner_buildium_id: resolvedOwnerBuildiumId,
_arc_files: files, _arc_files: files,
_arc_buildium_association_id: String(ba.Id), _arc_buildium_association_id: String(ba.Id),
_arc_decider_name: deciderName, _arc_decider_name: deciderName,
_arc_decider_date: deciderDate, _arc_decider_date: deciderDate,
_arc_decision: r.Decision || null,
}; };
const match = arcByBuildium.get(`${assocLocalId}|${buildiumArcId}`); const match = arcByBuildium.get(`${assocLocalId}|${buildiumArcId}`);
if (match) { 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, { const d = diff(match, {
status: incoming.status, status: incoming.status,
decision_notes: incoming.decision_notes, decision_notes: incoming.decision_notes,
review_date: incoming.review_date, review_date: incoming.review_date,
title: incoming.title, title: incoming.title,
description: incoming.description, description: incoming.description,
owner_id: incoming.owner_id,
unit_id: incoming.unit_id,
}); });
if (Object.keys(d).length === 0 && (!includeArcFiles || files.length === 0)) continue; if (Object.keys(d).length === 0 && (!includeArcFiles || files.length === 0)) continue;
stage( 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);
}
});
+154
View File
@@ -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();