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
|
- 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
@@ -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 />} />
|
||||||
|
|||||||
@@ -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 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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"],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,34 +264,8 @@ 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}>
|
||||||
@@ -370,19 +277,7 @@ export default function ComposeEmailPage() {
|
|||||||
</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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 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` : "") +
|
|
||||||
(reviewDate && !deciderDate ? `Decision date: ${reviewDate}\n` : "") +
|
|
||||||
(decisionNotes ? `Decision notes: ${decisionNotes}` : "");
|
|
||||||
await supabase.from("arc_application_comments").insert({
|
|
||||||
application_id: appId,
|
|
||||||
user_id: null,
|
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
|
// 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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