mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
Add ACMCC app source, Supabase backend, and project config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
v2.104.0
|
||||
@@ -0,0 +1 @@
|
||||
v2.189.0
|
||||
@@ -0,0 +1 @@
|
||||
{"ref":"yqdefzjapnzabowsgoyd","name":"ACMACC","organization_id":"ynnsgenuglucqcsawscy","organization_slug":"ynnsgenuglucqcsawscy"}
|
||||
@@ -0,0 +1 @@
|
||||
postgresql://postgres.yqdefzjapnzabowsgoyd@aws-1-us-east-1.pooler.supabase.com:5432/postgres
|
||||
@@ -0,0 +1 @@
|
||||
17.6.1.121
|
||||
@@ -0,0 +1 @@
|
||||
yqdefzjapnzabowsgoyd
|
||||
@@ -0,0 +1 @@
|
||||
v14.5
|
||||
@@ -0,0 +1 @@
|
||||
optimize-existing-functions-again
|
||||
@@ -0,0 +1 @@
|
||||
v1.58.22
|
||||
@@ -0,0 +1 @@
|
||||
project_id = "yqdefzjapnzabowsgoyd"
|
||||
@@ -0,0 +1,67 @@
|
||||
import * as React from 'npm:react@18.3.1'
|
||||
import { Body, Button, Container, Head, Heading, Html, Preview, Section, Text } from 'npm:@react-email/components@0.0.22'
|
||||
import type { TemplateEntry } from './registry.ts'
|
||||
|
||||
interface SignupCodeInviteProps {
|
||||
recipientName?: string
|
||||
associationName?: string
|
||||
code?: string
|
||||
link?: string
|
||||
roleLabel?: string
|
||||
expiresAt?: string
|
||||
}
|
||||
|
||||
const SITE_NAME = 'Avria Community Management'
|
||||
|
||||
const SignupCodeInviteEmail = ({ recipientName, associationName, code, link, roleLabel, expiresAt }: SignupCodeInviteProps) => (
|
||||
<Html lang="en" dir="ltr">
|
||||
<Head />
|
||||
<Preview>Your registration code for {associationName || SITE_NAME}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={brandBar} />
|
||||
<Heading style={h1}>You're invited to register</Heading>
|
||||
<Text style={text}>Hello {recipientName || 'there'},</Text>
|
||||
<Text style={text}>
|
||||
{associationName ? `${associationName}` : SITE_NAME} has set up an account for you
|
||||
{roleLabel ? ` as a ${roleLabel}` : ''}. Click the button below to complete your registration —
|
||||
your email will be verified automatically.
|
||||
</Text>
|
||||
{link && <Button href={link} style={button}>Complete registration</Button>}
|
||||
{code && (
|
||||
<Text style={meta}>
|
||||
Or enter this code manually: <strong style={{ color: '#111827' }}>{code}</strong>
|
||||
</Text>
|
||||
)}
|
||||
{expiresAt && <Text style={meta}>This invitation expires on {expiresAt}.</Text>}
|
||||
<Text style={footer}>
|
||||
If you weren't expecting this email, you can safely ignore it.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
|
||||
export const template = {
|
||||
component: SignupCodeInviteEmail,
|
||||
subject: (data: Record<string, any>) =>
|
||||
`Your registration code for ${data.associationName || SITE_NAME}`,
|
||||
displayName: 'Sign-up code invitation',
|
||||
previewData: {
|
||||
recipientName: 'Jane Smith',
|
||||
associationName: 'Sample HOA',
|
||||
code: 'ABC123XYZ9',
|
||||
link: 'https://avria.cloud/register/ABC123XYZ9',
|
||||
roleLabel: 'Homeowner',
|
||||
expiresAt: 'June 1, 2026',
|
||||
},
|
||||
} satisfies TemplateEntry
|
||||
|
||||
const main = { backgroundColor: '#ffffff', fontFamily: 'Inter, Arial, sans-serif' }
|
||||
const container = { maxWidth: '600px', margin: '0 auto', padding: '28px 24px' }
|
||||
const brandBar = { height: '5px', backgroundColor: '#2563eb', borderRadius: '6px', marginBottom: '24px' }
|
||||
const h1 = { color: '#111827', fontSize: '24px', lineHeight: '32px', margin: '0 0 14px', fontWeight: '700' }
|
||||
const text = { color: '#374151', fontSize: '15px', lineHeight: '24px', margin: '0 0 18px' }
|
||||
const meta = { color: '#6b7280', fontSize: '13px', lineHeight: '20px', margin: '14px 0 0' }
|
||||
const button = { backgroundColor: '#2563eb', color: '#ffffff', borderRadius: '6px', fontSize: '14px', fontWeight: '600', textDecoration: 'none', padding: '12px 18px' }
|
||||
const footer = { color: '#6b7280', fontSize: '12px', lineHeight: '18px', margin: '28px 0 0' }
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from 'npm:react@18.3.1'
|
||||
import { Body, Container, Head, Heading, Html, Preview, Text, Button } from 'npm:@react-email/components@0.0.22'
|
||||
import type { TemplateEntry } from './registry.ts'
|
||||
|
||||
const SITE_NAME = 'Avria Community Management'
|
||||
const DEFAULT_LINK = 'https://avria.cloud/dashboard/tasks'
|
||||
|
||||
interface Props {
|
||||
taskTitle?: string
|
||||
assignedByName?: string
|
||||
link?: string
|
||||
}
|
||||
|
||||
const TaskNotificationEmail = ({ taskTitle, assignedByName, link }: Props) => (
|
||||
<Html lang="en" dir="ltr">
|
||||
<Head />
|
||||
<Preview>New task assigned to you</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Heading style={h1}>New task assigned</Heading>
|
||||
<Text style={text}>
|
||||
{assignedByName ? `${assignedByName} has assigned ` : 'You have been assigned '}
|
||||
a new task:
|
||||
</Text>
|
||||
<Text style={taskBox}><strong>{taskTitle || 'New task'}</strong></Text>
|
||||
<Button href={link || DEFAULT_LINK} style={button}>View Task</Button>
|
||||
<Text style={footer}>This notification was sent by {SITE_NAME}.</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
|
||||
export const template = {
|
||||
component: TaskNotificationEmail,
|
||||
subject: (d: Record<string, any>) => `New Task Assigned: ${d.taskTitle || 'New task'}`,
|
||||
displayName: 'Task notification',
|
||||
previewData: { taskTitle: 'Review monthly financial report', assignedByName: 'Jane Admin', link: DEFAULT_LINK },
|
||||
} satisfies TemplateEntry
|
||||
|
||||
const main = { backgroundColor: '#ffffff', fontFamily: 'Inter, Arial, sans-serif' }
|
||||
const container = { maxWidth: '600px', margin: '0 auto', padding: '28px 24px' }
|
||||
const h1 = { color: '#111827', fontSize: '22px', lineHeight: '30px', margin: '0 0 14px', fontWeight: '700' }
|
||||
const text = { color: '#374151', fontSize: '15px', lineHeight: '24px', margin: '0 0 12px' }
|
||||
const taskBox = { background: '#f6f7f9', border: '1px solid #e5e7eb', borderRadius: '6px', padding: '12px 14px', color: '#111827', fontSize: '15px', margin: '0 0 20px' }
|
||||
const button = { backgroundColor: '#2941a4', color: '#ffffff', borderRadius: '6px', fontSize: '14px', fontWeight: '600', textDecoration: 'none', padding: '12px 18px' }
|
||||
const footer = { color: '#6b7280', fontSize: '12px', lineHeight: '18px', margin: '28px 0 0' }
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as React from 'npm:react@18.3.1'
|
||||
import { Body, Button, Container, Head, Heading, Html, Preview, Section, Text } from 'npm:@react-email/components@0.0.22'
|
||||
import type { TemplateEntry } from './registry.ts'
|
||||
|
||||
interface TenantInfoRequestProps {
|
||||
ownerName?: string
|
||||
propertyAddress?: string
|
||||
associationName?: string
|
||||
requesterName?: string
|
||||
customMessage?: string
|
||||
link?: string
|
||||
expiresAt?: string
|
||||
}
|
||||
|
||||
const SITE_NAME = 'Avria Community Management'
|
||||
|
||||
const TenantInfoRequestEmail = ({ ownerName, propertyAddress, associationName, requesterName, customMessage, link, expiresAt }: TenantInfoRequestProps) => (
|
||||
<Html lang="en" dir="ltr">
|
||||
<Head />
|
||||
<Preview>Tenant information requested for {propertyAddress || 'your property'}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={brandBar} />
|
||||
<Heading style={h1}>Tenant information requested</Heading>
|
||||
<Text style={text}>Hello {ownerName || 'Owner'},</Text>
|
||||
<Text style={text}>
|
||||
{requesterName || SITE_NAME} is requesting current tenant information
|
||||
{propertyAddress ? ` for the property at ${propertyAddress}` : ''}
|
||||
{associationName ? ` (${associationName})` : ''}.
|
||||
Please use the secure link below to submit or update this information.
|
||||
</Text>
|
||||
{customMessage && (
|
||||
<Section style={messageBox}>
|
||||
<Text style={messageText}>{customMessage}</Text>
|
||||
</Section>
|
||||
)}
|
||||
{link && <Button href={link} style={button}>Submit tenant information</Button>}
|
||||
{expiresAt && <Text style={meta}>This link expires on {expiresAt}.</Text>}
|
||||
<Text style={footer}>This request was sent by {SITE_NAME}. If you weren't expecting it, you can ignore this email.</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
|
||||
export const template = {
|
||||
component: TenantInfoRequestEmail,
|
||||
subject: (data: Record<string, any>) =>
|
||||
`Action needed: Submit tenant information${data.propertyAddress ? ` for ${data.propertyAddress}` : ''}`,
|
||||
displayName: 'Tenant info request',
|
||||
previewData: {
|
||||
ownerName: 'Jane Owner',
|
||||
propertyAddress: '123 Main Street',
|
||||
associationName: 'Sunset Hills HOA',
|
||||
requesterName: 'Avria Community Management',
|
||||
customMessage: 'Please update your tenant details before the end of the month.',
|
||||
link: 'https://avria.cloud/tenant-info/sample-token',
|
||||
expiresAt: 'June 1, 2026',
|
||||
},
|
||||
} satisfies TemplateEntry
|
||||
|
||||
const main = { backgroundColor: '#ffffff', fontFamily: 'Inter, Arial, sans-serif' }
|
||||
const container = { maxWidth: '600px', margin: '0 auto', padding: '28px 24px' }
|
||||
const brandBar = { height: '5px', backgroundColor: '#2563eb', borderRadius: '6px', marginBottom: '24px' }
|
||||
const h1 = { color: '#111827', fontSize: '24px', lineHeight: '32px', margin: '0 0 14px', fontWeight: '700' }
|
||||
const text = { color: '#374151', fontSize: '15px', lineHeight: '24px', margin: '0 0 18px' }
|
||||
const messageBox = { backgroundColor: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: '8px', padding: '14px 16px', margin: '0 0 20px' }
|
||||
const messageText = { color: '#0369a1', fontSize: '14px', lineHeight: '22px', margin: '0', whiteSpace: 'pre-wrap' as const }
|
||||
const meta = { color: '#6b7280', fontSize: '13px', lineHeight: '20px', margin: '14px 0 0' }
|
||||
const button = { backgroundColor: '#2563eb', color: '#ffffff', borderRadius: '6px', fontSize: '14px', fontWeight: '600', textDecoration: 'none', padding: '12px 18px' }
|
||||
const footer = { color: '#6b7280', fontSize: '12px', lineHeight: '18px', margin: '28px 0 0' }
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as React from 'npm:react@18.3.1'
|
||||
import { Body, Button, Container, Head, Heading, Html, Preview, Section, Text } from 'npm:@react-email/components@0.0.22'
|
||||
import type { TemplateEntry } from './registry.ts'
|
||||
|
||||
interface TicketResponseProps {
|
||||
recipientName?: string
|
||||
responderName?: string
|
||||
ticketTitle?: string
|
||||
response?: string
|
||||
link?: string
|
||||
}
|
||||
|
||||
const SITE_NAME = 'Avria Community Management'
|
||||
|
||||
const TicketResponseEmail = ({ recipientName, responderName, ticketTitle, response, link }: TicketResponseProps) => (
|
||||
<Html lang="en" dir="ltr">
|
||||
<Head />
|
||||
<Preview>New response on your ticket: {ticketTitle || 'Support request'}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={brandBar} />
|
||||
<Heading style={h1}>New response on your ticket</Heading>
|
||||
<Text style={text}>{recipientName ? `${recipientName}, ` : ''}{responderName || 'Staff'} replied to your homeowner ticket.</Text>
|
||||
<Section style={panel}>
|
||||
<Text style={label}>Ticket</Text>
|
||||
<Text style={value}>{ticketTitle || 'Support request'}</Text>
|
||||
</Section>
|
||||
{response && <Text style={quote}>{response}</Text>}
|
||||
{link && <Button href={link} style={button}>View ticket</Button>}
|
||||
<Text style={footer}>This notification was sent by {SITE_NAME}.</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
|
||||
export const template = {
|
||||
component: TicketResponseEmail,
|
||||
subject: (data: Record<string, any>) => `New response: ${data.ticketTitle || 'Your homeowner ticket'}`,
|
||||
displayName: 'Ticket response notification',
|
||||
previewData: {
|
||||
recipientName: 'Demo Homeowner',
|
||||
responderName: 'Avria Staff',
|
||||
ticketTitle: 'Gate access issue',
|
||||
response: 'Thanks for the details. We are reviewing this now and will follow up shortly.',
|
||||
link: 'https://avria.cloud/homeowner/tickets',
|
||||
},
|
||||
} satisfies TemplateEntry
|
||||
|
||||
const main = { backgroundColor: '#ffffff', fontFamily: 'Inter, Arial, sans-serif' }
|
||||
const container = { maxWidth: '600px', margin: '0 auto', padding: '28px 24px' }
|
||||
const brandBar = { height: '5px', backgroundColor: '#2563eb', borderRadius: '6px', marginBottom: '24px' }
|
||||
const h1 = { color: '#111827', fontSize: '24px', lineHeight: '32px', margin: '0 0 14px', fontWeight: '700' }
|
||||
const text = { color: '#374151', fontSize: '15px', lineHeight: '24px', margin: '0 0 18px' }
|
||||
const panel = { backgroundColor: '#f8fafc', border: '1px solid #e5e7eb', borderRadius: '8px', padding: '16px', margin: '20px 0' }
|
||||
const label = { color: '#6b7280', fontSize: '12px', textTransform: 'uppercase' as const, letterSpacing: '0.04em', margin: '0 0 6px' }
|
||||
const value = { color: '#111827', fontSize: '17px', lineHeight: '24px', fontWeight: '700', margin: '0' }
|
||||
const quote = { color: '#374151', fontSize: '14px', lineHeight: '22px', backgroundColor: '#f8fafc', borderLeft: '4px solid #2563eb', padding: '12px 14px', margin: '0 0 22px', whiteSpace: 'pre-wrap' as const }
|
||||
const button = { backgroundColor: '#2563eb', color: '#ffffff', borderRadius: '6px', fontSize: '14px', fontWeight: '600', textDecoration: 'none', padding: '12px 18px' }
|
||||
const footer = { color: '#6b7280', fontSize: '12px', lineHeight: '18px', margin: '28px 0 0' }
|
||||
@@ -0,0 +1,65 @@
|
||||
import * as React from 'npm:react@18.3.1'
|
||||
import { Body, Button, Container, Head, Heading, Html, Preview, Section, Text } from 'npm:@react-email/components@0.0.22'
|
||||
import type { TemplateEntry } from './registry.ts'
|
||||
|
||||
interface TicketSubmittedProps {
|
||||
recipientName?: string
|
||||
homeownerName?: string
|
||||
ticketTitle?: string
|
||||
priority?: string
|
||||
category?: string
|
||||
summary?: string
|
||||
link?: string
|
||||
}
|
||||
|
||||
const SITE_NAME = 'Avria Community Management'
|
||||
|
||||
const TicketSubmittedEmail = ({ recipientName, homeownerName, ticketTitle, priority, category, summary, link }: TicketSubmittedProps) => (
|
||||
<Html lang="en" dir="ltr">
|
||||
<Head />
|
||||
<Preview>New homeowner ticket: {ticketTitle || 'Support request'}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={brandBar} />
|
||||
<Heading style={h1}>New homeowner ticket</Heading>
|
||||
<Text style={text}>{recipientName ? `${recipientName}, ` : ''}a homeowner submitted a ticket that needs staff review.</Text>
|
||||
<Section style={panel}>
|
||||
<Text style={label}>Ticket</Text>
|
||||
<Text style={value}>{ticketTitle || 'Support request'}</Text>
|
||||
<Text style={meta}>{homeownerName || 'Homeowner'}{category ? ` · ${category}` : ''}{priority ? ` · ${priority} priority` : ''}</Text>
|
||||
</Section>
|
||||
{summary && <Text style={summaryStyle}>{summary}</Text>}
|
||||
{link && <Button href={link} style={button}>Open ticket inbox</Button>}
|
||||
<Text style={footer}>This notification was sent by {SITE_NAME}.</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
|
||||
export const template = {
|
||||
component: TicketSubmittedEmail,
|
||||
subject: (data: Record<string, any>) => `New homeowner ticket: ${data.ticketTitle || 'Support request'}`,
|
||||
displayName: 'Ticket submitted notification',
|
||||
previewData: {
|
||||
recipientName: 'Alex',
|
||||
homeownerName: 'Demo Homeowner',
|
||||
ticketTitle: 'Gate access issue',
|
||||
priority: 'medium',
|
||||
category: 'general',
|
||||
summary: 'Issue Description:\nThe gate code is not working.\n\nProperty: 100 Main Street',
|
||||
link: 'https://avria.cloud/dashboard/form-inbox',
|
||||
},
|
||||
} satisfies TemplateEntry
|
||||
|
||||
const main = { backgroundColor: '#ffffff', fontFamily: 'Inter, Arial, sans-serif' }
|
||||
const container = { maxWidth: '600px', margin: '0 auto', padding: '28px 24px' }
|
||||
const brandBar = { height: '5px', backgroundColor: '#2563eb', borderRadius: '6px', marginBottom: '24px' }
|
||||
const h1 = { color: '#111827', fontSize: '24px', lineHeight: '32px', margin: '0 0 14px', fontWeight: '700' }
|
||||
const text = { color: '#374151', fontSize: '15px', lineHeight: '24px', margin: '0 0 18px' }
|
||||
const panel = { backgroundColor: '#f8fafc', border: '1px solid #e5e7eb', borderRadius: '8px', padding: '16px', margin: '20px 0' }
|
||||
const label = { color: '#6b7280', fontSize: '12px', textTransform: 'uppercase' as const, letterSpacing: '0.04em', margin: '0 0 6px' }
|
||||
const value = { color: '#111827', fontSize: '17px', lineHeight: '24px', fontWeight: '700', margin: '0 0 6px' }
|
||||
const meta = { color: '#6b7280', fontSize: '13px', lineHeight: '20px', margin: '0' }
|
||||
const summaryStyle = { color: '#374151', fontSize: '14px', lineHeight: '22px', whiteSpace: 'pre-wrap' as const, margin: '0 0 22px' }
|
||||
const button = { backgroundColor: '#2563eb', color: '#ffffff', borderRadius: '6px', fontSize: '14px', fontWeight: '600', textDecoration: 'none', padding: '12px 18px' }
|
||||
const footer = { color: '#6b7280', fontSize: '12px', lineHeight: '18px', margin: '28px 0 0' }
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as React from 'npm:react@18.3.1'
|
||||
import { Body, Button, Container, Head, Heading, Html, Preview, Section, Text } from 'npm:@react-email/components@0.0.22'
|
||||
import type { TemplateEntry } from './registry.ts'
|
||||
|
||||
interface VendorInsuranceRequestProps {
|
||||
vendorName?: string
|
||||
requesterName?: string
|
||||
link?: string
|
||||
expiresAt?: string
|
||||
}
|
||||
|
||||
const SITE_NAME = 'Avria Community Management'
|
||||
|
||||
const VendorInsuranceRequestEmail = ({ vendorName, requesterName, link, expiresAt }: VendorInsuranceRequestProps) => (
|
||||
<Html lang="en" dir="ltr">
|
||||
<Head />
|
||||
<Preview>Insurance information requested for {vendorName || 'your account'}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={brandBar} />
|
||||
<Heading style={h1}>Insurance information requested</Heading>
|
||||
<Text style={text}>
|
||||
Hello {vendorName || 'Vendor'},
|
||||
</Text>
|
||||
<Text style={text}>
|
||||
{requesterName || SITE_NAME} is requesting your current certificate of insurance information.
|
||||
Please use the secure link below to submit your insurance carrier, policy number, and expiration date.
|
||||
You can also upload a copy of your certificate.
|
||||
</Text>
|
||||
{link && <Button href={link} style={button}>Submit insurance information</Button>}
|
||||
{expiresAt && (
|
||||
<Text style={meta}>This link expires on {expiresAt}.</Text>
|
||||
)}
|
||||
<Text style={footer}>This request was sent by {SITE_NAME}. If you weren't expecting it, you can ignore this email.</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
|
||||
export const template = {
|
||||
component: VendorInsuranceRequestEmail,
|
||||
subject: (data: Record<string, any>) =>
|
||||
`Action needed: Submit insurance information for ${data.vendorName || 'your account'}`,
|
||||
displayName: 'Vendor insurance request',
|
||||
previewData: {
|
||||
vendorName: 'Acme Plumbing',
|
||||
requesterName: 'Avria Community Management',
|
||||
link: 'https://avria.cloud/vendor-insurance/sample-token',
|
||||
expiresAt: 'June 1, 2026',
|
||||
},
|
||||
} satisfies TemplateEntry
|
||||
|
||||
const main = { backgroundColor: '#ffffff', fontFamily: 'Inter, Arial, sans-serif' }
|
||||
const container = { maxWidth: '600px', margin: '0 auto', padding: '28px 24px' }
|
||||
const brandBar = { height: '5px', backgroundColor: '#2563eb', borderRadius: '6px', marginBottom: '24px' }
|
||||
const h1 = { color: '#111827', fontSize: '24px', lineHeight: '32px', margin: '0 0 14px', fontWeight: '700' }
|
||||
const text = { color: '#374151', fontSize: '15px', lineHeight: '24px', margin: '0 0 18px' }
|
||||
const meta = { color: '#6b7280', fontSize: '13px', lineHeight: '20px', margin: '14px 0 0' }
|
||||
const button = { backgroundColor: '#2563eb', color: '#ffffff', borderRadius: '6px', fontSize: '14px', fontWeight: '600', textDecoration: 'none', padding: '12px 18px' }
|
||||
const footer = { color: '#6b7280', fontSize: '12px', lineHeight: '18px', margin: '28px 0 0' }
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as React from 'npm:react@18.3.1'
|
||||
import { Body, Button, Container, Head, Heading, Html, Preview, Section, Text } from 'npm:@react-email/components@0.0.22'
|
||||
import type { TemplateEntry } from './registry.ts'
|
||||
|
||||
interface VendorProfileRequestProps {
|
||||
vendorName?: string
|
||||
requesterName?: string
|
||||
link?: string
|
||||
expiresAt?: string
|
||||
}
|
||||
|
||||
const SITE_NAME = 'Avria Community Management'
|
||||
|
||||
const VendorProfileRequestEmail = ({ vendorName, requesterName, link, expiresAt }: VendorProfileRequestProps) => (
|
||||
<Html lang="en" dir="ltr">
|
||||
<Head />
|
||||
<Preview>Vendor profile information requested for {vendorName || 'your account'}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={brandBar} />
|
||||
<Heading style={h1}>Vendor profile update requested</Heading>
|
||||
<Text style={text}>Hello {vendorName || 'Vendor'},</Text>
|
||||
<Text style={text}>
|
||||
{requesterName || SITE_NAME} is requesting that you submit or update your vendor profile.
|
||||
Please use the secure link below to provide your remittance address, Tax ID (W-9 / 1099 status),
|
||||
insurance documentation, and direct deposit (ACH) details.
|
||||
</Text>
|
||||
{link && <Button href={link} style={button}>Submit vendor profile</Button>}
|
||||
{expiresAt && <Text style={meta}>This secure link expires on {expiresAt}.</Text>}
|
||||
<Text style={footer}>
|
||||
This request was sent by {SITE_NAME}. If you weren't expecting it, you can ignore this email.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
|
||||
export const template = {
|
||||
component: VendorProfileRequestEmail,
|
||||
subject: (data: Record<string, any>) =>
|
||||
`Action needed: Submit vendor profile for ${data.vendorName || 'your account'}`,
|
||||
displayName: 'Vendor profile request',
|
||||
previewData: {
|
||||
vendorName: 'Acme Plumbing',
|
||||
requesterName: 'Avria Community Management',
|
||||
link: 'https://avria.cloud/vendor-profile/sample-token',
|
||||
expiresAt: 'June 1, 2026',
|
||||
},
|
||||
} satisfies TemplateEntry
|
||||
|
||||
const main = { backgroundColor: '#ffffff', fontFamily: 'Inter, Arial, sans-serif' }
|
||||
const container = { maxWidth: '600px', margin: '0 auto', padding: '28px 24px' }
|
||||
const brandBar = { height: '5px', backgroundColor: '#2563eb', borderRadius: '6px', marginBottom: '24px' }
|
||||
const h1 = { color: '#111827', fontSize: '24px', lineHeight: '32px', margin: '0 0 14px', fontWeight: '700' }
|
||||
const text = { color: '#374151', fontSize: '15px', lineHeight: '24px', margin: '0 0 18px' }
|
||||
const meta = { color: '#6b7280', fontSize: '13px', lineHeight: '20px', margin: '14px 0 0' }
|
||||
const button = { backgroundColor: '#2563eb', color: '#ffffff', borderRadius: '6px', fontSize: '14px', fontWeight: '600', textDecoration: 'none', padding: '12px 18px' }
|
||||
const footer = { color: '#6b7280', fontSize: '12px', lineHeight: '18px', margin: '28px 0 0' }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,117 @@
|
||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||
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, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||
|
||||
try {
|
||||
const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY");
|
||||
if (!LOVABLE_API_KEY) throw new Error("LOVABLE_API_KEY is not configured");
|
||||
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
const { question, association_id, history } = await req.json();
|
||||
if (!question || !association_id) {
|
||||
return new Response(JSON.stringify({ error: "question and association_id are required" }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch FAQs for the association
|
||||
const { data: faqs } = await supabase
|
||||
.from("association_faqs")
|
||||
.select("question, answer")
|
||||
.eq("association_id", association_id)
|
||||
.order("sort_order");
|
||||
|
||||
const faqList = (faqs || [])
|
||||
.filter((f: any) => f.answer)
|
||||
.map((f: any, i: number) => `${i + 1}. Q: ${f.question}\n A: ${f.answer}`)
|
||||
.join("\n\n");
|
||||
|
||||
// Fetch association name for context
|
||||
const { data: assoc } = await supabase
|
||||
.from("associations")
|
||||
.select("name")
|
||||
.eq("id", association_id)
|
||||
.single();
|
||||
|
||||
const assocName = assoc?.name || "the community";
|
||||
|
||||
const systemPrompt = `You are a friendly and helpful community assistant for ${assocName}.
|
||||
|
||||
You are available on the community's public page to help visitors and residents with questions.
|
||||
|
||||
You have a knowledge base of pre-determined questions and answers. Try to match the visitor's question to one of these FAQs and provide the answer. You can rephrase answers naturally but keep the same information.
|
||||
|
||||
If the question is clearly covered by one or more FAQs, answer it using that information.
|
||||
If the question is NOT covered by any FAQ, or you're unsure, politely let them know you don't have that information and suggest they contact management directly using the contact information on this page.
|
||||
|
||||
Do NOT make up answers. Only answer from the provided FAQ knowledge base.
|
||||
|
||||
Here are the available FAQs:
|
||||
${faqList || "No FAQs have been configured yet."}`;
|
||||
|
||||
// Build messages with conversation history
|
||||
const messages: any[] = [{ role: "system", content: systemPrompt }];
|
||||
if (history && Array.isArray(history)) {
|
||||
for (const msg of history.slice(-10)) {
|
||||
messages.push({ role: msg.role, content: msg.content });
|
||||
}
|
||||
}
|
||||
messages.push({ role: "user", content: question });
|
||||
|
||||
const aiResponse = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${LOVABLE_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "google/gemini-3-flash-preview",
|
||||
messages,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!aiResponse.ok) {
|
||||
if (aiResponse.status === 429) {
|
||||
return new Response(JSON.stringify({ error: "Rate limited, please try again later." }), {
|
||||
status: 429,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (aiResponse.status === 402) {
|
||||
return new Response(JSON.stringify({ error: "Service temporarily unavailable." }), {
|
||||
status: 402,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
const errText = await aiResponse.text();
|
||||
console.error("AI gateway error:", aiResponse.status, errText);
|
||||
throw new Error("AI gateway error");
|
||||
}
|
||||
|
||||
const aiData = await aiResponse.json();
|
||||
const answer = aiData.choices?.[0]?.message?.content || "I'm sorry, I couldn't process your question. Please try again.";
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ answer }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("ai-public-chat error:", e);
|
||||
return new Response(
|
||||
JSON.stringify({ error: e instanceof Error ? e.message : "Unknown error" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||
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, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||
|
||||
try {
|
||||
const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY");
|
||||
if (!LOVABLE_API_KEY) throw new Error("LOVABLE_API_KEY is not configured");
|
||||
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Validate user from auth header
|
||||
const authHeader = req.headers.get("authorization") ?? "";
|
||||
const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
|
||||
const userClient = createClient(supabaseUrl, anonKey, {
|
||||
global: { headers: { Authorization: authHeader } },
|
||||
});
|
||||
const { data: { user }, error: authErr } = await userClient.auth.getUser();
|
||||
if (authErr || !user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const { question, association_id, chat_id } = await req.json();
|
||||
if (!question || !association_id) {
|
||||
return new Response(JSON.stringify({ error: "question and association_id are required" }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch FAQs for the association
|
||||
const { data: faqs } = await supabase
|
||||
.from("association_faqs")
|
||||
.select("question, answer")
|
||||
.eq("association_id", association_id)
|
||||
.order("sort_order");
|
||||
|
||||
const faqList = (faqs || [])
|
||||
.filter((f: any) => f.answer)
|
||||
.map((f: any, i: number) => `${i + 1}. Q: ${f.question}\n A: ${f.answer}`)
|
||||
.join("\n\n");
|
||||
|
||||
const systemPrompt = `You are a friendly and helpful customer support assistant for a homeowners association community.
|
||||
|
||||
You have a knowledge base of pre-determined questions and answers. Try to match the homeowner's question to one of these FAQs and provide the answer. You can rephrase answers naturally but keep the same information.
|
||||
|
||||
If the question is clearly covered by one or more FAQs, answer it using that information.
|
||||
If the question is NOT covered by any FAQ, or you're unsure, respond with EXACTLY this JSON format:
|
||||
{"escalate": true, "reason": "Brief summary of what the homeowner is asking about"}
|
||||
|
||||
Do NOT make up answers. Only answer from the provided FAQ knowledge base.
|
||||
|
||||
Here are the available FAQs:
|
||||
${faqList || "No FAQs have been configured yet."}`;
|
||||
|
||||
// Call Lovable AI
|
||||
const aiResponse = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${LOVABLE_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "google/gemini-3-flash-preview",
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: question },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!aiResponse.ok) {
|
||||
if (aiResponse.status === 429) {
|
||||
return new Response(JSON.stringify({ error: "Rate limited, please try again later." }), {
|
||||
status: 429,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (aiResponse.status === 402) {
|
||||
return new Response(JSON.stringify({ error: "Service temporarily unavailable." }), {
|
||||
status: 402,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
const errText = await aiResponse.text();
|
||||
console.error("AI gateway error:", aiResponse.status, errText);
|
||||
throw new Error("AI gateway error");
|
||||
}
|
||||
|
||||
const aiData = await aiResponse.json();
|
||||
const aiMessage = aiData.choices?.[0]?.message?.content || "";
|
||||
|
||||
// Check if the AI wants to escalate
|
||||
let escalated = false;
|
||||
let answer = aiMessage;
|
||||
let escalateReason = "";
|
||||
|
||||
try {
|
||||
// Try parsing as JSON escalation response
|
||||
const cleaned = aiMessage.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim();
|
||||
const parsed = JSON.parse(cleaned);
|
||||
if (parsed.escalate === true) {
|
||||
escalated = true;
|
||||
escalateReason = parsed.reason || question;
|
||||
answer = "I wasn't able to find an answer to your question in our knowledge base. I've forwarded your question to our management team, and someone will get back to you shortly.";
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — it's a normal answer
|
||||
}
|
||||
|
||||
// Ensure/create a chat record
|
||||
let activeChatId = chat_id;
|
||||
if (!activeChatId) {
|
||||
const { data: newChat } = await supabase
|
||||
.from("support_chats")
|
||||
.insert({ user_id: user.id, association_id, status: "open" })
|
||||
.select("id")
|
||||
.single();
|
||||
activeChatId = newChat?.id;
|
||||
}
|
||||
|
||||
if (activeChatId) {
|
||||
// Save user message
|
||||
await supabase.from("support_chat_messages").insert({
|
||||
chat_id: activeChatId,
|
||||
role: "user",
|
||||
content: question,
|
||||
});
|
||||
// Save AI response
|
||||
await supabase.from("support_chat_messages").insert({
|
||||
chat_id: activeChatId,
|
||||
role: "assistant",
|
||||
content: answer,
|
||||
escalated,
|
||||
});
|
||||
}
|
||||
|
||||
// If escalated, notify all admins
|
||||
if (escalated) {
|
||||
// Get user's name
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("full_name")
|
||||
.eq("user_id", user.id)
|
||||
.single();
|
||||
const userName = profile?.full_name || user.email || "A homeowner";
|
||||
|
||||
// Get all admin user IDs
|
||||
const { data: adminRoles } = await supabase
|
||||
.from("user_roles")
|
||||
.select("user_id")
|
||||
.eq("role", "admin");
|
||||
|
||||
const adminIds = (adminRoles || []).map((r: any) => r.user_id);
|
||||
|
||||
// Send notification to each admin
|
||||
for (const adminId of adminIds) {
|
||||
await supabase.rpc("insert_notification", {
|
||||
p_user_id: adminId,
|
||||
p_type: "support",
|
||||
p_title: "Support Request",
|
||||
p_message: `${userName} asked: "${question.substring(0, 100)}${question.length > 100 ? "..." : ""}"`,
|
||||
});
|
||||
}
|
||||
|
||||
// Also send a direct message to each admin
|
||||
for (const adminId of adminIds) {
|
||||
await supabase.from("direct_messages").insert({
|
||||
sender_id: user.id,
|
||||
recipient_id: adminId,
|
||||
message: `[Support Request] ${userName} asked a question that couldn't be answered by the AI assistant:\n\n"${question}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ answer, escalated, chat_id: activeChatId }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("ai-support-chat error:", e);
|
||||
return new Response(
|
||||
JSON.stringify({ error: e instanceof Error ? e.message : "Unknown error" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.99.1";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||
|
||||
try {
|
||||
const authHeader = req.headers.get("authorization") || "";
|
||||
if (!authHeader.startsWith("Bearer ")) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
|
||||
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
|
||||
const callerClient = createClient(supabaseUrl, anonKey, { global: { headers: { Authorization: authHeader } } });
|
||||
const adminClient = createClient(supabaseUrl, serviceRoleKey);
|
||||
|
||||
const { data: { user }, error: userError } = await callerClient.auth.getUser();
|
||||
if (userError || !user?.id || !user.email) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
const normalizedEmail = user.email.trim().toLowerCase();
|
||||
const displayName = (user.user_metadata?.full_name as string | undefined) || normalizedEmail;
|
||||
|
||||
const { data: committees, error: committeeError } = await adminClient
|
||||
.from("arc_committee_members")
|
||||
.select("id, association_id, is_active")
|
||||
.or(`user_id.eq.${user.id},email.ilike.${normalizedEmail}`);
|
||||
if (committeeError) throw committeeError;
|
||||
|
||||
// Ensure caller has arc_member role if they're on any committee roster
|
||||
if ((committees ?? []).some((c) => c.is_active)) {
|
||||
const { data: existingRole } = await adminClient
|
||||
.from("user_roles")
|
||||
.select("role")
|
||||
.eq("user_id", user.id)
|
||||
.eq("role", "arc_member")
|
||||
.maybeSingle();
|
||||
if (!existingRole) {
|
||||
await adminClient.from("user_roles").insert({ user_id: user.id, role: "arc_member" });
|
||||
}
|
||||
}
|
||||
|
||||
const { data: owners, error: ownersError } = await adminClient
|
||||
.from("owners")
|
||||
.select("association_id, first_name, last_name")
|
||||
.eq("user_id", user.id)
|
||||
.neq("status", "archived");
|
||||
if (ownersError) throw ownersError;
|
||||
|
||||
const committeeAssociationIds = (committees ?? []).filter((c) => c.is_active).map((c) => c.association_id).filter(Boolean);
|
||||
const allCommitteeAssociationIds = (committees ?? []).map((c) => c.association_id).filter(Boolean);
|
||||
const ownerAssociationIds = (owners ?? []).map((o) => o.association_id).filter(Boolean);
|
||||
const inactiveRosterIds = (committees ?? [])
|
||||
.filter((c) => !c.is_active && ownerAssociationIds.includes(c.association_id))
|
||||
.map((c) => c.id);
|
||||
const missingRosterAssociationIds = ownerAssociationIds.filter((id) => !allCommitteeAssociationIds.includes(id));
|
||||
|
||||
if (inactiveRosterIds.length > 0) {
|
||||
const { error: activateError } = await adminClient
|
||||
.from("arc_committee_members")
|
||||
.update({ is_active: true })
|
||||
.in("id", inactiveRosterIds);
|
||||
if (activateError) throw activateError;
|
||||
}
|
||||
|
||||
if (missingRosterAssociationIds.length > 0) {
|
||||
const ownerName = owners?.find((o) => o.first_name || o.last_name);
|
||||
const name = ownerName ? `${ownerName.first_name || ""} ${ownerName.last_name || ""}`.trim() : displayName;
|
||||
const rows = [...new Set(missingRosterAssociationIds)].map((association_id) => ({
|
||||
association_id,
|
||||
name,
|
||||
email: normalizedEmail,
|
||||
is_active: true,
|
||||
role: "Member",
|
||||
}));
|
||||
const { error: insertError } = await adminClient.from("arc_committee_members").insert(rows);
|
||||
if (insertError) throw insertError;
|
||||
}
|
||||
|
||||
const associationIds = [...new Set([...committeeAssociationIds, ...ownerAssociationIds])];
|
||||
if (!associationIds.length) {
|
||||
return new Response(JSON.stringify({ applications: [], associationIds, noCommittee: true }), { headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
const { data: applications, error: appError } = await adminClient
|
||||
.from("arc_applications")
|
||||
.select("*, associations(name), units(unit_number, address), owners(first_name, last_name, property_address)")
|
||||
.in("association_id", associationIds)
|
||||
.order("created_at", { ascending: false });
|
||||
if (appError) throw appError;
|
||||
|
||||
return new Response(JSON.stringify({ applications: applications ?? [], associationIds, noCommittee: false }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load ARC reviews";
|
||||
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
// SendGrid Inbound Parse webhook for ARC applications.
|
||||
// Receives multipart/form-data POST when an email arrives at the configured
|
||||
// inbound hostname (e.g. arc@parse.yourdomain.com). Uploads attachments to the
|
||||
// arc-files bucket, runs Lovable AI (Gemini) over the email body to extract
|
||||
// project type / title / description / property address, inserts a row in
|
||||
// arc_inbound_emails, and creates a form_inbox entry so staff get notified.
|
||||
|
||||
import { createClient } from 'npm:@supabase/supabase-js@2'
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
}
|
||||
|
||||
function extractEmail(raw: string | null): { email: string | null; name: string | null } {
|
||||
if (!raw) return { email: null, name: null }
|
||||
const m = raw.match(/(?:"?([^"<]*)"?\s*)?<?([^\s<>]+@[^\s<>]+)>?/)
|
||||
if (!m) return { email: null, name: null }
|
||||
return { email: m[2].trim().toLowerCase(), name: (m[1] || '').trim() || null }
|
||||
}
|
||||
|
||||
async function aiExtract(subject: string | null, body: string | null, fromName: string | null) {
|
||||
const apiKey = Deno.env.get('LOVABLE_API_KEY')
|
||||
if (!apiKey) return null
|
||||
const prompt = `You are extracting fields for an Architectural Review Committee (ARC) application from a homeowner email.
|
||||
Email subject: ${subject || '(none)'}
|
||||
From: ${fromName || '(unknown)'}
|
||||
Body:
|
||||
"""
|
||||
${(body || '').slice(0, 8000)}
|
||||
"""`
|
||||
|
||||
try {
|
||||
const resp = await fetch('https://ai.gateway.lovable.dev/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'google/gemini-2.5-flash',
|
||||
messages: [
|
||||
{ role: 'system', content: 'Extract structured ARC application info. Be concise and factual; never invent.' },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
tools: [{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'extract_arc',
|
||||
description: 'Return structured ARC application fields',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', description: 'Short title for the request (e.g. "Fence replacement", "Exterior paint").' },
|
||||
project_type: { type: 'string', description: 'Category like Paint, Fence, Roof, Landscaping, Addition, Other.' },
|
||||
description: { type: 'string', description: 'Concise summary of the proposed work.' },
|
||||
property_address: { type: 'string', description: 'Property street address if mentioned, else empty string.' },
|
||||
},
|
||||
required: ['title', 'project_type', 'description', 'property_address'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
}],
|
||||
tool_choice: { type: 'function', function: { name: 'extract_arc' } },
|
||||
}),
|
||||
})
|
||||
if (!resp.ok) {
|
||||
console.error('AI gateway error', resp.status, await resp.text())
|
||||
return null
|
||||
}
|
||||
const data = await resp.json()
|
||||
const args = data?.choices?.[0]?.message?.tool_calls?.[0]?.function?.arguments
|
||||
if (!args) return null
|
||||
return JSON.parse(args)
|
||||
} catch (e) {
|
||||
console.error('AI extract failed', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') return new Response(null, { headers: corsHeaders })
|
||||
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405, headers: corsHeaders })
|
||||
|
||||
const supabase = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!)
|
||||
|
||||
let formData: FormData
|
||||
try {
|
||||
formData = await req.formData()
|
||||
} catch (err) {
|
||||
console.error('Failed to parse form data', err)
|
||||
return new Response('Bad Request', { status: 400, headers: corsHeaders })
|
||||
}
|
||||
|
||||
const fromRaw = formData.get('from')?.toString() ?? null
|
||||
const toRaw = formData.get('to')?.toString() ?? null
|
||||
const subject = formData.get('subject')?.toString() ?? null
|
||||
const text = formData.get('text')?.toString() ?? null
|
||||
const html = formData.get('html')?.toString() ?? null
|
||||
const attachmentCount = parseInt(formData.get('attachments')?.toString() ?? '0', 10)
|
||||
|
||||
const { email: fromEmail, name: fromName } = extractEmail(fromRaw)
|
||||
const { email: toEmail } = extractEmail(toRaw)
|
||||
|
||||
console.log('Inbound ARC email', { fromEmail, toEmail, subject, attachmentCount })
|
||||
|
||||
// Upload attachments to arc-files bucket
|
||||
const attachmentUrls: Array<{ name: string; url: string; type: string; size: number }> = []
|
||||
for (let i = 1; i <= attachmentCount; i++) {
|
||||
const file = formData.get(`attachment${i}`) as File | null
|
||||
if (!file) continue
|
||||
try {
|
||||
const buf = await file.arrayBuffer()
|
||||
const safeName = (file.name || `attachment-${i}`).replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||
const path = `inbound/${new Date().toISOString().slice(0, 10)}/${crypto.randomUUID()}-${safeName}`
|
||||
const { error: upErr } = await supabase.storage.from('arc-files').upload(path, buf, {
|
||||
contentType: file.type || 'application/octet-stream',
|
||||
upsert: false,
|
||||
})
|
||||
if (upErr) {
|
||||
console.error('Attachment upload failed', upErr)
|
||||
continue
|
||||
}
|
||||
const { data: pub } = supabase.storage.from('arc-files').getPublicUrl(path)
|
||||
attachmentUrls.push({ name: file.name || safeName, url: pub.publicUrl, type: file.type || '', size: file.size })
|
||||
} catch (e) {
|
||||
console.error('Attachment processing error', e)
|
||||
}
|
||||
}
|
||||
|
||||
// AI extract
|
||||
const ai = await aiExtract(subject, text || html, fromName)
|
||||
|
||||
// Insert inbound record
|
||||
const { data: inbound, error: insErr } = await supabase
|
||||
.from('arc_inbound_emails')
|
||||
.insert({
|
||||
from_email: fromEmail,
|
||||
from_name: fromName,
|
||||
to_email: toEmail,
|
||||
subject,
|
||||
body_text: text,
|
||||
body_html: html,
|
||||
attachment_urls: attachmentUrls,
|
||||
ai_project_type: ai?.project_type ?? null,
|
||||
ai_title: ai?.title ?? null,
|
||||
ai_description: ai?.description ?? null,
|
||||
ai_property_address: ai?.property_address ?? null,
|
||||
ai_raw: ai ?? null,
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (insErr || !inbound) {
|
||||
console.error('Insert failed', insErr)
|
||||
return new Response(JSON.stringify({ ok: false, error: insErr?.message }), {
|
||||
status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
// Create form_inbox entry — triggers staff notification
|
||||
const summary = [
|
||||
ai?.title ? `AI: ${ai.title}` : null,
|
||||
ai?.project_type ? `Type: ${ai.project_type}` : null,
|
||||
attachmentUrls.length ? `${attachmentUrls.length} attachment${attachmentUrls.length > 1 ? 's' : ''}` : null,
|
||||
(text || '').slice(0, 120),
|
||||
].filter(Boolean).join(' | ').slice(0, 400)
|
||||
|
||||
await supabase.from('form_inbox').insert({
|
||||
source_type: 'arc_inbound_email',
|
||||
source_id: inbound.id,
|
||||
association_id: null,
|
||||
title: ai?.title || subject || 'ARC Email Submission',
|
||||
submitter_name: fromName,
|
||||
submitter_email: fromEmail,
|
||||
summary,
|
||||
})
|
||||
|
||||
return new Response(JSON.stringify({ ok: true, id: inbound.id }), {
|
||||
status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "npm:react@18.3.1",
|
||||
"types": ["npm:@types/react@18.3.1"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import * as React from 'npm:react@18.3.1'
|
||||
import { renderAsync } from 'npm:@react-email/components@0.0.22'
|
||||
import { parseEmailWebhookPayload } from 'npm:@lovable.dev/email-js'
|
||||
import { WebhookError, verifyWebhookRequest } from 'npm:@lovable.dev/webhooks-js'
|
||||
import { createClient } from 'npm:@supabase/supabase-js@2'
|
||||
import { SignupEmail } from '../_shared/email-templates/signup.tsx'
|
||||
import { InviteEmail } from '../_shared/email-templates/invite.tsx'
|
||||
import { MagicLinkEmail } from '../_shared/email-templates/magic-link.tsx'
|
||||
import { RecoveryEmail } from '../_shared/email-templates/recovery.tsx'
|
||||
import { EmailChangeEmail } from '../_shared/email-templates/email-change.tsx'
|
||||
import { ReauthenticationEmail } from '../_shared/email-templates/reauthentication.tsx'
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers':
|
||||
'authorization, x-client-info, apikey, content-type, x-lovable-signature, x-lovable-timestamp, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version',
|
||||
}
|
||||
|
||||
const EMAIL_SUBJECTS: Record<string, string> = {
|
||||
signup: 'Confirm your email',
|
||||
invite: "You've been invited",
|
||||
magiclink: 'Your login link',
|
||||
recovery: 'Reset your password',
|
||||
email_change: 'Confirm your new email',
|
||||
reauthentication: 'Your verification code',
|
||||
}
|
||||
|
||||
// Template mapping
|
||||
const EMAIL_TEMPLATES: Record<string, React.ComponentType<any>> = {
|
||||
signup: SignupEmail,
|
||||
invite: InviteEmail,
|
||||
magiclink: MagicLinkEmail,
|
||||
recovery: RecoveryEmail,
|
||||
email_change: EmailChangeEmail,
|
||||
reauthentication: ReauthenticationEmail,
|
||||
}
|
||||
|
||||
// Configuration
|
||||
const SITE_NAME = "Avria Community Management, LLC"
|
||||
const SENDER_DOMAIN = "notify.avriamail.com"
|
||||
const ROOT_DOMAIN = "avria.cloud"
|
||||
const FROM_DOMAIN = "notify.avriamail.com" // Domain shown in From address (may be root or sender subdomain)
|
||||
const FROM_ADDRESS = `"${SITE_NAME.replace(/"/g, '\\"')}" <noreply@${FROM_DOMAIN}>`
|
||||
|
||||
// Sample data for preview mode ONLY (not used in actual email sending).
|
||||
// URLs are baked in at scaffold time from the project's real data.
|
||||
// The sample email uses a fixed placeholder (RFC 6761 .test TLD) so the Go backend
|
||||
// can always find-and-replace it with the actual recipient when sending test emails,
|
||||
// even if the project's domain has changed since the template was scaffolded.
|
||||
const SAMPLE_PROJECT_URL = "https://avria.cloud"
|
||||
const SAMPLE_EMAIL = "user@example.test"
|
||||
const SAMPLE_DATA: Record<string, object> = {
|
||||
signup: {
|
||||
siteName: SITE_NAME,
|
||||
siteUrl: SAMPLE_PROJECT_URL,
|
||||
recipient: SAMPLE_EMAIL,
|
||||
confirmationUrl: SAMPLE_PROJECT_URL,
|
||||
},
|
||||
magiclink: {
|
||||
siteName: SITE_NAME,
|
||||
confirmationUrl: SAMPLE_PROJECT_URL,
|
||||
},
|
||||
recovery: {
|
||||
siteName: SITE_NAME,
|
||||
confirmationUrl: SAMPLE_PROJECT_URL,
|
||||
},
|
||||
invite: {
|
||||
siteName: SITE_NAME,
|
||||
siteUrl: SAMPLE_PROJECT_URL,
|
||||
confirmationUrl: SAMPLE_PROJECT_URL,
|
||||
},
|
||||
email_change: {
|
||||
siteName: SITE_NAME,
|
||||
email: SAMPLE_EMAIL,
|
||||
newEmail: SAMPLE_EMAIL,
|
||||
confirmationUrl: SAMPLE_PROJECT_URL,
|
||||
},
|
||||
reauthentication: {
|
||||
token: '123456',
|
||||
},
|
||||
}
|
||||
|
||||
// Preview endpoint handler - returns rendered HTML without sending email
|
||||
async function handlePreview(req: Request): Promise<Response> {
|
||||
const previewCorsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, content-type',
|
||||
}
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { headers: previewCorsHeaders })
|
||||
}
|
||||
|
||||
const apiKey = Deno.env.get('LOVABLE_API_KEY')
|
||||
const authHeader = req.headers.get('Authorization')
|
||||
|
||||
if (!apiKey || authHeader !== `Bearer ${apiKey}`) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { ...previewCorsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
let type: string
|
||||
try {
|
||||
const body = await req.json()
|
||||
type = body.type
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid JSON in request body' }), {
|
||||
status: 400,
|
||||
headers: { ...previewCorsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const EmailTemplate = EMAIL_TEMPLATES[type]
|
||||
|
||||
if (!EmailTemplate) {
|
||||
return new Response(JSON.stringify({ error: `Unknown email type: ${type}` }), {
|
||||
status: 400,
|
||||
headers: { ...previewCorsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const sampleData = SAMPLE_DATA[type] || {}
|
||||
const html = await renderAsync(React.createElement(EmailTemplate, sampleData))
|
||||
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: { ...previewCorsHeaders, 'Content-Type': 'text/html; charset=utf-8' },
|
||||
})
|
||||
}
|
||||
|
||||
// Webhook handler - verifies signature and sends email
|
||||
async function handleWebhook(req: Request): Promise<Response> {
|
||||
const apiKey = Deno.env.get('LOVABLE_API_KEY')
|
||||
|
||||
if (!apiKey) {
|
||||
console.error('LOVABLE_API_KEY not configured')
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Server configuration error' }),
|
||||
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify signature + timestamp, then parse payload.
|
||||
let payload: any
|
||||
let run_id = ''
|
||||
try {
|
||||
const verified = await verifyWebhookRequest({
|
||||
req,
|
||||
secret: apiKey,
|
||||
parser: parseEmailWebhookPayload,
|
||||
})
|
||||
payload = verified.payload
|
||||
run_id = payload.run_id
|
||||
} catch (error) {
|
||||
if (error instanceof WebhookError) {
|
||||
switch (error.code) {
|
||||
case 'invalid_signature':
|
||||
case 'missing_timestamp':
|
||||
case 'invalid_timestamp':
|
||||
case 'stale_timestamp':
|
||||
console.error('Invalid webhook signature', { error: error.message })
|
||||
return new Response(JSON.stringify({ error: 'Invalid signature' }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
case 'invalid_payload':
|
||||
case 'invalid_json':
|
||||
console.error('Invalid webhook payload', { error: error.message })
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Invalid webhook payload' }),
|
||||
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Webhook verification failed', { error })
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Invalid webhook payload' }),
|
||||
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
if (!run_id) {
|
||||
console.error('Webhook payload missing run_id')
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Invalid webhook payload' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (payload.version !== '1') {
|
||||
console.error('Unsupported payload version', { version: payload.version, run_id })
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Unsupported payload version: ${payload.version}` }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// The email action type is in payload.data.action_type (e.g., "signup", "recovery")
|
||||
// payload.type is the hook event type ("auth")
|
||||
const emailType = payload.data.action_type
|
||||
console.log('Received auth event', { emailType, email: payload.data.email, run_id })
|
||||
|
||||
const EmailTemplate = EMAIL_TEMPLATES[emailType]
|
||||
if (!EmailTemplate) {
|
||||
console.error('Unknown email type', { emailType, run_id })
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Unknown email type: ${emailType}` }),
|
||||
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
// Build template props from payload.data (HookData structure)
|
||||
const templateProps = {
|
||||
siteName: SITE_NAME,
|
||||
siteUrl: `https://${ROOT_DOMAIN}`,
|
||||
recipient: payload.data.email,
|
||||
confirmationUrl: payload.data.url,
|
||||
token: payload.data.token,
|
||||
email: payload.data.email,
|
||||
newEmail: payload.data.new_email,
|
||||
}
|
||||
|
||||
// Render React Email to HTML and plain text
|
||||
const html = await renderAsync(React.createElement(EmailTemplate, templateProps))
|
||||
const text = await renderAsync(React.createElement(EmailTemplate, templateProps), {
|
||||
plainText: true,
|
||||
})
|
||||
|
||||
// Enqueue email for async processing by the dispatcher (process-email-queue).
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||||
)
|
||||
|
||||
const messageId = crypto.randomUUID()
|
||||
|
||||
// Log pending BEFORE enqueue so we have a record even if enqueue crashes
|
||||
await supabase.from('email_send_log').insert({
|
||||
message_id: messageId,
|
||||
template_name: emailType,
|
||||
recipient_email: payload.data.email,
|
||||
status: 'pending',
|
||||
})
|
||||
|
||||
const { error: enqueueError } = await supabase.rpc('enqueue_email', {
|
||||
queue_name: 'auth_emails',
|
||||
payload: {
|
||||
run_id,
|
||||
message_id: messageId,
|
||||
to: payload.data.email,
|
||||
from: FROM_ADDRESS,
|
||||
sender_domain: SENDER_DOMAIN,
|
||||
subject: EMAIL_SUBJECTS[emailType] || 'Notification',
|
||||
html,
|
||||
text,
|
||||
purpose: 'transactional',
|
||||
label: emailType,
|
||||
queued_at: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
if (enqueueError) {
|
||||
console.error('Failed to enqueue auth email', { error: enqueueError, run_id, emailType })
|
||||
await supabase.from('email_send_log').insert({
|
||||
message_id: messageId,
|
||||
template_name: emailType,
|
||||
recipient_email: payload.data.email,
|
||||
status: 'failed',
|
||||
error_message: 'Failed to enqueue email',
|
||||
})
|
||||
return new Response(JSON.stringify({ error: 'Failed to enqueue email' }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
console.log('Auth email enqueued', { emailType, email: payload.data.email, run_id })
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, queued: true }),
|
||||
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const url = new URL(req.url)
|
||||
|
||||
// Handle CORS preflight for main endpoint
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
// Route to preview handler for /preview path
|
||||
if (url.pathname.endsWith('/preview')) {
|
||||
return handlePreview(req)
|
||||
}
|
||||
|
||||
// Main webhook handler
|
||||
try {
|
||||
return await handleWebhook(req)
|
||||
} catch (error) {
|
||||
console.error('Webhook handler error:', error)
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
// Avria Sign — public endpoints: lookup envelope by token, submit signature
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||
|
||||
const url = new URL(req.url);
|
||||
const action = url.searchParams.get("action") || "lookup";
|
||||
const admin = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);
|
||||
|
||||
try {
|
||||
if (action === "lookup") {
|
||||
const tokenStr = url.searchParams.get("token");
|
||||
if (!tokenStr) return json({ error: "missing token" }, 400);
|
||||
|
||||
const { data: recip, error: rErr } = await admin
|
||||
.from("signature_recipients")
|
||||
.select("id, envelope_id, name, email, status, signed_at")
|
||||
.eq("signing_token", tokenStr)
|
||||
.maybeSingle();
|
||||
if (rErr || !recip) return json({ error: "invalid token" }, 404);
|
||||
|
||||
const { data: env } = await admin
|
||||
.from("signature_envelopes")
|
||||
.select("id, document_name, document_url, signed_document_url, status, email_body, association_id, voided_at, voided_reason")
|
||||
.eq("id", recip.envelope_id)
|
||||
.maybeSingle();
|
||||
|
||||
// If voided, return early
|
||||
if (env?.status === "voided" || env?.voided_at) {
|
||||
return json({ recipient: recip, envelope: env, voided: true });
|
||||
}
|
||||
|
||||
// Fetch fields for this recipient
|
||||
const { data: fields } = await admin
|
||||
.from("signature_fields")
|
||||
.select("id, field_type, page_number, x_ratio, y_ratio, width_ratio, height_ratio, required")
|
||||
.eq("recipient_id", recip.id)
|
||||
.order("page_number");
|
||||
|
||||
// log view event (only first time)
|
||||
if (recip.status === "pending") {
|
||||
await admin.from("signature_events").insert({
|
||||
envelope_id: recip.envelope_id,
|
||||
recipient_id: recip.id,
|
||||
event_type: "viewed",
|
||||
ip_address: req.headers.get("x-forwarded-for") || null,
|
||||
user_agent: req.headers.get("user-agent") || null,
|
||||
});
|
||||
await admin.from("signature_recipients").update({ status: "viewed" }).eq("id", recip.id).eq("status", "pending");
|
||||
}
|
||||
|
||||
return json({ recipient: recip, envelope: env, fields: fields || [] });
|
||||
}
|
||||
|
||||
if (action === "sign" && req.method === "POST") {
|
||||
const body = await req.json();
|
||||
const { token, signature_data_url, signature_method } = body;
|
||||
if (!token || !signature_data_url) return json({ error: "missing fields" }, 400);
|
||||
|
||||
const { data: recip } = await admin
|
||||
.from("signature_recipients")
|
||||
.select("id, envelope_id, status")
|
||||
.eq("signing_token", token)
|
||||
.maybeSingle();
|
||||
if (!recip) return json({ error: "invalid token" }, 404);
|
||||
if (recip.status === "signed") return json({ error: "already signed" }, 400);
|
||||
|
||||
// Refuse if envelope voided
|
||||
const { data: envCheck } = await admin
|
||||
.from("signature_envelopes").select("status, voided_at").eq("id", recip.envelope_id).maybeSingle();
|
||||
if (envCheck?.status === "voided" || envCheck?.voided_at) {
|
||||
return json({ error: "This envelope has been voided and can no longer be signed." }, 400);
|
||||
}
|
||||
|
||||
const ip = req.headers.get("x-forwarded-for") || null;
|
||||
const ua = req.headers.get("user-agent") || null;
|
||||
|
||||
await admin.from("signature_recipients").update({
|
||||
signature_data_url,
|
||||
signature_method: signature_method || "draw",
|
||||
status: "signed",
|
||||
signed_at: new Date().toISOString(),
|
||||
signed_ip: ip,
|
||||
signed_user_agent: ua,
|
||||
}).eq("id", recip.id);
|
||||
|
||||
await admin.from("signature_events").insert({
|
||||
envelope_id: recip.envelope_id,
|
||||
recipient_id: recip.id,
|
||||
event_type: "signed",
|
||||
ip_address: ip,
|
||||
user_agent: ua,
|
||||
details: { method: signature_method || "draw" },
|
||||
});
|
||||
|
||||
const { data: allRecips } = await admin
|
||||
.from("signature_recipients")
|
||||
.select("status")
|
||||
.eq("envelope_id", recip.envelope_id);
|
||||
|
||||
const allSigned = (allRecips || []).every(r => r.status === "signed");
|
||||
if (allSigned) {
|
||||
await admin.from("signature_envelopes").update({
|
||||
status: "completed",
|
||||
completed_at: new Date().toISOString(),
|
||||
}).eq("id", recip.envelope_id);
|
||||
|
||||
await admin.from("signature_events").insert({
|
||||
envelope_id: recip.envelope_id,
|
||||
event_type: "completed",
|
||||
});
|
||||
|
||||
admin.functions.invoke("avria-sign-stamp", {
|
||||
body: { envelope_id: recip.envelope_id },
|
||||
}).catch(e => console.warn("stamp invoke failed:", e));
|
||||
}
|
||||
|
||||
return json({ ok: true, all_signed: allSigned });
|
||||
}
|
||||
|
||||
return json({ error: "unknown action" }, 400);
|
||||
} catch (err: any) {
|
||||
console.error("avria-sign-public error:", err);
|
||||
return json({ error: err.message || String(err) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
function json(payload: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// Avria Sign — send envelope (in-house e-signature)
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
interface Recipient { name: string; email: string }
|
||||
interface FieldPayload {
|
||||
recipient_index: number;
|
||||
field_type: "signature" | "date" | "name";
|
||||
page_number: number;
|
||||
x_ratio: number;
|
||||
y_ratio: number;
|
||||
width_ratio: number;
|
||||
height_ratio: number;
|
||||
}
|
||||
|
||||
function toSmtpSenderConfig(sender: any) {
|
||||
const port = Number(sender?.smtp_port ?? 587);
|
||||
const isImplicitSslPort = port === 465;
|
||||
const isStartTlsPort = port === 587;
|
||||
|
||||
return {
|
||||
host: sender.smtp_host,
|
||||
port,
|
||||
username: sender.smtp_username,
|
||||
password: sender.smtp_password,
|
||||
use_ssl: isImplicitSslPort ? true : sender.use_ssl ?? false,
|
||||
use_tls: isImplicitSslPort ? false : sender.use_tls ?? isStartTlsPort,
|
||||
from: sender.sender_name
|
||||
? `${sender.sender_name} <${sender.email_address}>`
|
||||
: sender.email_address,
|
||||
fromEmail: sender.email_address,
|
||||
fromName: sender.sender_name,
|
||||
envelopeFrom: sender.smtp_username || sender.email_address,
|
||||
signature_html: sender.signature_html || "",
|
||||
};
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const authHeader = req.headers.get("Authorization") || "";
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
|
||||
const userClient = createClient(supabaseUrl, Deno.env.get("SUPABASE_ANON_KEY")!, {
|
||||
global: { headers: { Authorization: authHeader } },
|
||||
});
|
||||
const { data: userData } = await userClient.auth.getUser(token);
|
||||
if (!userData?.user) {
|
||||
return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
const admin = createClient(supabaseUrl, serviceKey);
|
||||
const body = await req.json();
|
||||
const {
|
||||
association_id,
|
||||
document_name,
|
||||
document_url,
|
||||
document_base64,
|
||||
file_extension = "pdf",
|
||||
recipients,
|
||||
email_subject,
|
||||
email_body,
|
||||
fields = [],
|
||||
}: {
|
||||
association_id?: string;
|
||||
document_name: string;
|
||||
document_url?: string;
|
||||
document_base64?: string;
|
||||
file_extension?: string;
|
||||
recipients: Recipient[];
|
||||
email_subject?: string;
|
||||
email_body?: string;
|
||||
fields?: FieldPayload[];
|
||||
} = body;
|
||||
|
||||
if (!document_name || !recipients?.length) {
|
||||
return new Response(JSON.stringify({ error: "missing document_name or recipients" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
// 1) Insert envelope
|
||||
const { data: env, error: envErr } = await admin.from("signature_envelopes").insert({
|
||||
association_id: association_id || null,
|
||||
document_name,
|
||||
document_url: document_url || "",
|
||||
email_subject,
|
||||
email_body,
|
||||
status: "sent",
|
||||
created_by: userData.user.id,
|
||||
sent_at: new Date().toISOString(),
|
||||
}).select().single();
|
||||
if (envErr) throw envErr;
|
||||
|
||||
// 2) If raw upload, store
|
||||
let finalDocUrl = document_url || "";
|
||||
if (document_base64) {
|
||||
const binary = Uint8Array.from(atob(document_base64), c => c.charCodeAt(0));
|
||||
const path = `signature-envelopes/${env.id}/original.${file_extension}`;
|
||||
const { error: upErr } = await admin.storage.from("files").upload(path, binary, {
|
||||
contentType: file_extension === "pdf" ? "application/pdf" : "application/octet-stream",
|
||||
upsert: true,
|
||||
});
|
||||
if (upErr) throw upErr;
|
||||
const { data: pub } = admin.storage.from("files").getPublicUrl(path);
|
||||
finalDocUrl = pub.publicUrl;
|
||||
await admin.from("signature_envelopes").update({ document_url: finalDocUrl }).eq("id", env.id);
|
||||
}
|
||||
|
||||
// 3) Insert recipients
|
||||
const recipientRows = recipients.map((r, idx) => ({
|
||||
envelope_id: env.id,
|
||||
name: r.name.trim(),
|
||||
email: r.email.trim().toLowerCase(),
|
||||
signing_order: idx + 1,
|
||||
status: "pending",
|
||||
}));
|
||||
const { data: insertedRecips, error: rErr } = await admin.from("signature_recipients").insert(recipientRows).select();
|
||||
if (rErr) throw rErr;
|
||||
|
||||
// 3b) Insert placed fields, mapping recipient_index -> inserted recipient id
|
||||
if (fields.length > 0 && insertedRecips) {
|
||||
const fieldRows = fields
|
||||
.filter(f => insertedRecips[f.recipient_index])
|
||||
.map(f => ({
|
||||
envelope_id: env.id,
|
||||
recipient_id: insertedRecips[f.recipient_index].id,
|
||||
field_type: f.field_type,
|
||||
page_number: f.page_number,
|
||||
x_ratio: f.x_ratio,
|
||||
y_ratio: f.y_ratio,
|
||||
width_ratio: f.width_ratio,
|
||||
height_ratio: f.height_ratio,
|
||||
}));
|
||||
if (fieldRows.length > 0) {
|
||||
const { error: fErr } = await admin.from("signature_fields").insert(fieldRows);
|
||||
if (fErr) console.warn("field insert error:", fErr);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Audit
|
||||
await admin.from("signature_events").insert({
|
||||
envelope_id: env.id,
|
||||
event_type: "sent",
|
||||
details: { recipient_count: recipients.length, document_name, field_count: fields.length },
|
||||
});
|
||||
|
||||
// 5) Default sender
|
||||
const { data: defaultSender } = await admin
|
||||
.from("email_senders")
|
||||
.select("smtp_host, smtp_port, smtp_username, smtp_password, use_tls, use_ssl, email_address, sender_name, signature_html")
|
||||
.eq("is_active", true)
|
||||
.eq("is_default", true)
|
||||
.order("updated_at", { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
const smtpSender = defaultSender ? toSmtpSenderConfig(defaultSender) : null;
|
||||
|
||||
// 6) Email + notifications
|
||||
const appOrigin = req.headers.get("origin") || "https://avria.cloud";
|
||||
const subject = email_subject || `Please sign: ${document_name}`;
|
||||
|
||||
for (const recip of insertedRecips || []) {
|
||||
const signUrl = `${appOrigin}/sign/${recip.signing_token}`;
|
||||
const html = `
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
|
||||
<h2 style="color:#1e3a8a;">Signature Requested</h2>
|
||||
<p>Hi ${recip.name},</p>
|
||||
<p>You have been requested to electronically sign the following document:</p>
|
||||
<p style="font-size:16px;font-weight:bold;color:#1e3a8a;">${document_name}</p>
|
||||
${email_body ? `<p style="white-space:pre-wrap;">${email_body}</p>` : ""}
|
||||
<p style="margin:30px 0;">
|
||||
<a href="${signUrl}" style="background:#1e3a8a;color:#fff;padding:12px 24px;text-decoration:none;border-radius:6px;display:inline-block;">Review & Sign Document</a>
|
||||
</p>
|
||||
<p style="font-size:12px;color:#666;">Or copy this link into your browser:<br/>${signUrl}</p>
|
||||
<hr style="margin-top:30px;border:none;border-top:1px solid #eee;"/>
|
||||
<p style="font-size:11px;color:#999;">Avria Sign — Secure Electronic Signatures</p>
|
||||
</div>`;
|
||||
|
||||
if (smtpSender) {
|
||||
try {
|
||||
await admin.functions.invoke("send-smtp-email", {
|
||||
body: {
|
||||
sender: smtpSender,
|
||||
recipient: recip.email,
|
||||
subject,
|
||||
html,
|
||||
body: html,
|
||||
fallback_user_id: userData.user.id,
|
||||
feature_type: "signature_request",
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(`SMTP email failed for ${recip.email}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { data: existingUser } = await admin
|
||||
.from("profiles").select("user_id").ilike("email", recip.email).maybeSingle();
|
||||
const targetUserId = existingUser?.user_id || recip.user_id;
|
||||
if (targetUserId) {
|
||||
await admin.rpc("insert_notification", {
|
||||
p_user_id: targetUserId,
|
||||
p_type: "signature_request",
|
||||
p_title: "Signature requested",
|
||||
p_message: `Please sign: ${document_name}`,
|
||||
p_related_item_id: env.id,
|
||||
p_related_item_type: "signature_envelope",
|
||||
p_link: `/sign/${recip.signing_token}`,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Notification failed for ${recip.email}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
envelope_id: env.id,
|
||||
recipient_count: insertedRecips?.length || 0,
|
||||
field_count: fields.length,
|
||||
}), { headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
} catch (err: any) {
|
||||
console.error("avria-sign-send error:", err);
|
||||
return new Response(JSON.stringify({ error: err.message || String(err) }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
// Avria Sign — stamp signed PDF (using placed fields when available) and append validation certificate
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
|
||||
import { PDFDocument, StandardFonts, rgb } from "https://esm.sh/pdf-lib@1.17.1";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
function toSmtpSenderConfig(sender: any) {
|
||||
const port = Number(sender?.smtp_port ?? 587);
|
||||
const isImplicitSslPort = port === 465;
|
||||
const isStartTlsPort = port === 587;
|
||||
|
||||
return {
|
||||
host: sender.smtp_host,
|
||||
port,
|
||||
username: sender.smtp_username,
|
||||
password: sender.smtp_password,
|
||||
use_ssl: isImplicitSslPort ? true : sender.use_ssl ?? false,
|
||||
use_tls: isImplicitSslPort ? false : sender.use_tls ?? isStartTlsPort,
|
||||
from: sender.sender_name
|
||||
? `${sender.sender_name} <${sender.email_address}>`
|
||||
: sender.email_address,
|
||||
fromEmail: sender.email_address,
|
||||
fromName: sender.sender_name,
|
||||
envelopeFrom: sender.smtp_username || sender.email_address,
|
||||
signature_html: sender.signature_html || "",
|
||||
};
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||
const admin = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);
|
||||
|
||||
try {
|
||||
const { envelope_id } = await req.json();
|
||||
if (!envelope_id) return json({ error: "missing envelope_id" }, 400);
|
||||
|
||||
const { data: env } = await admin
|
||||
.from("signature_envelopes").select("*").eq("id", envelope_id).maybeSingle();
|
||||
if (!env) return json({ error: "envelope not found" }, 404);
|
||||
|
||||
const { data: recipients } = await admin
|
||||
.from("signature_recipients").select("*").eq("envelope_id", envelope_id).order("signing_order");
|
||||
|
||||
const { data: allFields } = await admin
|
||||
.from("signature_fields").select("*").eq("envelope_id", envelope_id);
|
||||
|
||||
const pdfRes = await fetch(env.document_url);
|
||||
if (!pdfRes.ok) throw new Error("Could not fetch original document");
|
||||
const pdfBytes = new Uint8Array(await pdfRes.arrayBuffer());
|
||||
|
||||
let pdfDoc: PDFDocument;
|
||||
try {
|
||||
pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true });
|
||||
} catch {
|
||||
pdfDoc = await PDFDocument.create();
|
||||
const p = pdfDoc.addPage();
|
||||
p.drawText("Original document could not be embedded.", { x: 50, y: 700, size: 12 });
|
||||
}
|
||||
|
||||
const helv = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const helvBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
const pages = pdfDoc.getPages();
|
||||
|
||||
// Cache embedded signature images per recipient
|
||||
const sigImageCache = new Map<string, any>();
|
||||
for (const r of recipients || []) {
|
||||
if (!r.signature_data_url) continue;
|
||||
try {
|
||||
const isPng = r.signature_data_url.includes("image/png");
|
||||
const base64 = r.signature_data_url.split(",")[1] || "";
|
||||
const sigBytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
|
||||
const img = isPng ? await pdfDoc.embedPng(sigBytes) : await pdfDoc.embedJpg(sigBytes);
|
||||
sigImageCache.set(r.id, img);
|
||||
} catch (e) { console.warn("sig embed failed:", r.id, e); }
|
||||
}
|
||||
|
||||
const fieldsByRecipient = new Map<string, any[]>();
|
||||
(allFields || []).forEach(f => {
|
||||
const arr = fieldsByRecipient.get(f.recipient_id) || [];
|
||||
arr.push(f); fieldsByRecipient.set(f.recipient_id, arr);
|
||||
});
|
||||
|
||||
const hasPlacedFields = (allFields || []).length > 0;
|
||||
|
||||
if (hasPlacedFields) {
|
||||
// Stamp at each placed field location
|
||||
for (const r of recipients || []) {
|
||||
const fields = fieldsByRecipient.get(r.id) || [];
|
||||
const sigImg = sigImageCache.get(r.id);
|
||||
const signedDate = r.signed_at ? new Date(r.signed_at) : new Date();
|
||||
const dateStr = signedDate.toLocaleDateString("en-US", { timeZone: "America/New_York" });
|
||||
|
||||
for (const f of fields) {
|
||||
const pageIdx = Math.min((f.page_number || 1) - 1, pages.length - 1);
|
||||
const page = pages[pageIdx];
|
||||
const { width: pw, height: ph } = page.getSize();
|
||||
// UI uses top-left origin; pdf-lib uses bottom-left
|
||||
const x = f.x_ratio * pw;
|
||||
const y = ph - (f.y_ratio + f.height_ratio) * ph;
|
||||
const w = f.width_ratio * pw;
|
||||
const h = f.height_ratio * ph;
|
||||
|
||||
if (f.field_type === "signature" && sigImg) {
|
||||
// Fit signature into box preserving aspect ratio
|
||||
const ratio = Math.min(w / sigImg.width, h / sigImg.height);
|
||||
const drawW = sigImg.width * ratio;
|
||||
const drawH = sigImg.height * ratio;
|
||||
page.drawImage(sigImg, {
|
||||
x: x + (w - drawW) / 2,
|
||||
y: y + (h - drawH) / 2,
|
||||
width: drawW, height: drawH,
|
||||
});
|
||||
} else if (f.field_type === "date") {
|
||||
page.drawText(dateStr, { x: x + 4, y: y + h / 2 - 4, size: Math.min(11, h * 0.7), font: helv, color: rgb(0, 0, 0) });
|
||||
} else if (f.field_type === "name") {
|
||||
page.drawText(r.name, { x: x + 4, y: y + h / 2 - 4, size: Math.min(11, h * 0.7), font: helvBold, color: rgb(0, 0, 0) });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: stamp on bottom of last page
|
||||
const lastPage = pages[pages.length - 1];
|
||||
let stampY = 80;
|
||||
for (const r of recipients || []) {
|
||||
const sigImg = sigImageCache.get(r.id);
|
||||
if (!sigImg) continue;
|
||||
const sigDims = sigImg.scale(120 / sigImg.width);
|
||||
lastPage.drawImage(sigImg, { x: 50, y: stampY, width: sigDims.width, height: sigDims.height });
|
||||
lastPage.drawText(`${r.name}`, { x: 180, y: stampY + 25, size: 9, font: helvBold, color: rgb(0, 0, 0) });
|
||||
lastPage.drawText(`Signed: ${new Date(r.signed_at).toLocaleString("en-US", { timeZone: "America/New_York" })} EST`, {
|
||||
x: 180, y: stampY + 12, size: 8, font: helv, color: rgb(0.3, 0.3, 0.3),
|
||||
});
|
||||
lastPage.drawText(`ID: ${r.id.slice(0, 8).toUpperCase()}`, {
|
||||
x: 180, y: stampY, size: 7, font: helv, color: rgb(0.5, 0.5, 0.5),
|
||||
});
|
||||
stampY += 60;
|
||||
if (stampY > 250) break;
|
||||
}
|
||||
}
|
||||
|
||||
// Append Certificate of Validation page
|
||||
const cert = pdfDoc.addPage();
|
||||
const { width, height } = cert.getSize();
|
||||
|
||||
cert.drawRectangle({ x: 30, y: 30, width: width - 60, height: height - 60, borderColor: rgb(0, 0, 0), borderWidth: 2 });
|
||||
cert.drawRectangle({ x: 40, y: 40, width: width - 80, height: height - 80, borderColor: rgb(0, 0, 0), borderWidth: 0.5 });
|
||||
|
||||
cert.drawText("CERTIFICATE OF VALIDATION", { x: width / 2 - 165, y: height - 110, size: 22, font: helvBold });
|
||||
cert.drawText("Cryptographic proof of document signing and integrity.", {
|
||||
x: width / 2 - 175, y: height - 135, size: 10, font: helv, color: rgb(0.4, 0.4, 0.4),
|
||||
});
|
||||
|
||||
let y = height - 200;
|
||||
const drawRow = (label: string, value: string) => {
|
||||
cert.drawText(label, { x: 70, y, size: 11, font: helvBold });
|
||||
cert.drawText(value, { x: 230, y, size: 11, font: helv });
|
||||
cert.drawLine({ start: { x: 70, y: y - 5 }, end: { x: width - 70, y: y - 5 }, thickness: 0.3, color: rgb(0.7, 0.7, 0.7) });
|
||||
y -= 30;
|
||||
};
|
||||
|
||||
drawRow("Document Name:", env.document_name);
|
||||
drawRow("Envelope ID:", env.id);
|
||||
drawRow("Issued By:", "Avria Community Management");
|
||||
drawRow("Completed:", new Date(env.completed_at || Date.now()).toLocaleString("en-US", { timeZone: "America/New_York" }) + " EST");
|
||||
drawRow("Status:", "COMPLETED");
|
||||
|
||||
y -= 10;
|
||||
cert.drawText("SIGNERS", { x: 70, y, size: 12, font: helvBold });
|
||||
y -= 20;
|
||||
|
||||
for (const r of recipients || []) {
|
||||
cert.drawText(`• ${r.name} <${r.email}>`, { x: 80, y, size: 10, font: helv });
|
||||
y -= 14;
|
||||
if (r.signed_at) {
|
||||
cert.drawText(
|
||||
` Signed ${new Date(r.signed_at).toLocaleString("en-US", { timeZone: "America/New_York" })} EST` +
|
||||
(r.signed_ip ? ` from ${r.signed_ip}` : "") +
|
||||
` (${r.signature_method || "draw"})`,
|
||||
{ x: 80, y, size: 8, font: helv, color: rgb(0.4, 0.4, 0.4) }
|
||||
);
|
||||
y -= 12;
|
||||
}
|
||||
cert.drawText(` Signature ID: ${r.id}`, { x: 80, y, size: 7, font: helv, color: rgb(0.5, 0.5, 0.5) });
|
||||
y -= 16;
|
||||
}
|
||||
|
||||
cert.drawCircle({ x: width - 110, y: 110, size: 45, borderColor: rgb(0.2, 0.4, 0.7), borderWidth: 3, opacity: 0.6 });
|
||||
cert.drawText("OFFICIAL", { x: width - 130, y: 110, size: 9, font: helvBold, color: rgb(0.2, 0.4, 0.7) });
|
||||
cert.drawText("SEAL", { x: width - 122, y: 98, size: 9, font: helvBold, color: rgb(0.2, 0.4, 0.7) });
|
||||
|
||||
const finalBytes = await pdfDoc.save();
|
||||
const path = `signature-envelopes/${envelope_id}/signed.pdf`;
|
||||
const { error: upErr } = await admin.storage.from("files").upload(path, finalBytes, {
|
||||
contentType: "application/pdf", upsert: true,
|
||||
});
|
||||
if (upErr) throw upErr;
|
||||
const { data: pub } = admin.storage.from("files").getPublicUrl(path);
|
||||
|
||||
await admin.from("signature_envelopes").update({ signed_document_url: pub.publicUrl }).eq("id", envelope_id);
|
||||
|
||||
// Notify all signers + creator
|
||||
const { data: envFull } = await admin.from("signature_envelopes").select("created_by, document_name").eq("id", envelope_id).single();
|
||||
const recipientEmails = (recipients || []).map(r => r.email);
|
||||
let creatorEmail: string | null = null;
|
||||
if (envFull?.created_by) {
|
||||
const { data: creator } = await admin.auth.admin.getUserById(envFull.created_by);
|
||||
creatorEmail = creator?.user?.email || null;
|
||||
}
|
||||
const allEmails = Array.from(new Set([...recipientEmails, ...(creatorEmail ? [creatorEmail] : [])]));
|
||||
|
||||
const { data: defaultSender } = await admin
|
||||
.from("email_senders").select("smtp_host, smtp_port, smtp_username, smtp_password, use_tls, use_ssl, email_address, sender_name, signature_html").eq("is_active", true).eq("is_default", true)
|
||||
.order("updated_at", { ascending: false }).limit(1).maybeSingle();
|
||||
|
||||
const smtpSender = defaultSender ? toSmtpSenderConfig(defaultSender) : null;
|
||||
|
||||
for (const email of allEmails) {
|
||||
try {
|
||||
if (smtpSender) {
|
||||
const html = `
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
|
||||
<h2 style="color:#059669;">Document Signed</h2>
|
||||
<p>The document <strong>${envFull?.document_name || "Document"}</strong> has been signed by all parties.</p>
|
||||
<p style="margin:20px 0;">
|
||||
<a href="${pub.publicUrl}" style="background:#1e3a8a;color:#fff;padding:12px 24px;text-decoration:none;border-radius:6px;display:inline-block;">Download Signed Copy</a>
|
||||
</p>
|
||||
<hr style="margin-top:30px;border:none;border-top:1px solid #eee;"/>
|
||||
<p style="font-size:11px;color:#999;">Avria Sign — Secure Electronic Signatures</p>
|
||||
</div>`;
|
||||
await admin.functions.invoke("send-smtp-email", {
|
||||
body: {
|
||||
sender: smtpSender,
|
||||
recipient: email,
|
||||
subject: `Signed: ${envFull?.document_name || "Document"}`,
|
||||
html,
|
||||
body: html,
|
||||
fallback_user_id: envFull?.created_by || null,
|
||||
feature_type: "signature_completed",
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Completion email failed for", email, e);
|
||||
}
|
||||
}
|
||||
|
||||
return json({ ok: true, signed_document_url: pub.publicUrl });
|
||||
} catch (err: any) {
|
||||
console.error("avria-sign-stamp error:", err);
|
||||
return json({ error: err.message || String(err) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
function json(payload: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Public endpoint for avriacam.com contact form submissions.
|
||||
// Inserts into form_inbox with source_type = 'avriacam_contact'.
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
};
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||
|
||||
try {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const name = String(body.name ?? body.full_name ?? "").trim();
|
||||
const email = String(body.email ?? "").trim();
|
||||
const subject = String(body.subject ?? body.topic ?? "Contact Form Submission").trim();
|
||||
const message = String(body.message ?? body.body ?? "").trim();
|
||||
const phone = body.phone ? String(body.phone).trim() : "";
|
||||
const company = body.company ? String(body.company).trim() : "";
|
||||
|
||||
if (!email || !message) {
|
||||
return new Response(JSON.stringify({ error: "email and message are required" }), {
|
||||
status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
|
||||
);
|
||||
|
||||
// Source id is synthetic (no underlying record). Use a uuid.
|
||||
const sourceId = crypto.randomUUID();
|
||||
|
||||
const summaryParts = [
|
||||
phone ? `Phone: ${phone}` : null,
|
||||
company ? `Company: ${company}` : null,
|
||||
message,
|
||||
].filter(Boolean);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("form_inbox")
|
||||
.insert({
|
||||
source_type: "avriacam_contact",
|
||||
source_id: sourceId,
|
||||
association_id: null,
|
||||
title: `[avriacam.com] ${subject}`,
|
||||
submitter_name: name || null,
|
||||
submitter_email: email,
|
||||
summary: summaryParts.join(" | ").slice(0, 1000),
|
||||
})
|
||||
.select("id")
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return new Response(JSON.stringify({ ok: true, id: data.id }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (e) {
|
||||
return new Response(JSON.stringify({ error: (e as Error).message }), {
|
||||
status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
// Buildium Import Apply — applies approved staged rows from buildium_import_staging
|
||||
// to live tables in dependency order (units → owners → gl_account → ledger_entry).
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
const KIND_ORDER = ["unit", "owner", "gl_account", "ledger_entry", "arc_application"] as const;
|
||||
const BUILDIUM_BASE = "https://api.buildium.com";
|
||||
|
||||
async function buildiumFetch(path: string, clientId: string, clientSecret: string): Promise<Response> {
|
||||
return fetch(`${BUILDIUM_BASE}${path}`, {
|
||||
headers: {
|
||||
"x-buildium-client-id": clientId,
|
||||
"x-buildium-client-secret": clientSecret,
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function stripPrivate(p: Record<string, any>): Record<string, any> {
|
||||
const out: Record<string, any> = {};
|
||||
for (const [k, v] of Object.entries(p)) if (!k.startsWith("_")) out[k] = v;
|
||||
return out;
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||
|
||||
try {
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
|
||||
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
|
||||
let userId: string | null = null;
|
||||
try {
|
||||
const payload = token.split(".")[1];
|
||||
const padded = payload.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(payload.length / 4) * 4, "=");
|
||||
userId = JSON.parse(atob(padded))?.sub ?? null;
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
const auth = createClient(supabaseUrl, anonKey, { global: { headers: { Authorization: authHeader } } });
|
||||
const { data: roles } = await auth.from("user_roles").select("role").eq("user_id", userId);
|
||||
const isStaff = (roles || []).some((r: any) => r.role === "admin" || r.role === "manager");
|
||||
if (!isStaff) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, serviceKey);
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const batchId: string | null = typeof body.batch_id === "string" ? body.batch_id : null;
|
||||
const stagingIds: string[] | null = Array.isArray(body.staging_ids) ? body.staging_ids.filter((s: any) => typeof s === "string") : null;
|
||||
|
||||
if (!batchId && (!stagingIds || stagingIds.length === 0)) {
|
||||
return new Response(JSON.stringify({ error: "Provide batch_id or staging_ids" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
// Fetch approved staged rows
|
||||
let q = supabase.from("buildium_import_staging").select("*").eq("status", "approved");
|
||||
if (batchId) q = q.eq("batch_id", batchId);
|
||||
if (stagingIds && stagingIds.length > 0) q = q.in("id", stagingIds);
|
||||
const { data: staged, error: stagedErr } = await q;
|
||||
if (stagedErr) throw stagedErr;
|
||||
|
||||
if (!staged || staged.length === 0) {
|
||||
return new Response(JSON.stringify({ success: true, applied: 0, failed: 0, message: "No approved rows to apply" }), {
|
||||
status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Group by kind
|
||||
const byKind: Record<string, any[]> = {};
|
||||
for (const k of KIND_ORDER) byKind[k] = [];
|
||||
for (const r of staged) if (KIND_ORDER.includes(r.kind)) byKind[r.kind].push(r);
|
||||
|
||||
let applied = 0, failed = 0;
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
async function markApplied(id: string) {
|
||||
await supabase.from("buildium_import_staging").update({
|
||||
status: "applied", applied_at: nowIso, apply_error: null,
|
||||
}).eq("id", id);
|
||||
applied++;
|
||||
}
|
||||
async function markFailed(id: string, msg: string) {
|
||||
await supabase.from("buildium_import_staging").update({
|
||||
status: "failed", apply_error: msg.slice(0, 1000),
|
||||
}).eq("id", id);
|
||||
failed++;
|
||||
}
|
||||
|
||||
// ---- 1. UNITS ----
|
||||
const newBuildiumUnitToLocalId = new Map<string, string>();
|
||||
for (const row of byKind.unit) {
|
||||
try {
|
||||
const p = stripPrivate(row.payload || {});
|
||||
if (row.action === "update" && row.match_id) {
|
||||
const { error } = await supabase.from("units").update(p).eq("id", row.match_id);
|
||||
if (error) throw error;
|
||||
if (p.buildium_unit_id) newBuildiumUnitToLocalId.set(String(p.buildium_unit_id), row.match_id);
|
||||
} else {
|
||||
const { data: ins, error } = await supabase.from("units").insert(p).select("id").single();
|
||||
if (error) throw error;
|
||||
if (p.buildium_unit_id) newBuildiumUnitToLocalId.set(String(p.buildium_unit_id), ins.id);
|
||||
}
|
||||
await markApplied(row.id);
|
||||
} catch (e: any) {
|
||||
await markFailed(row.id, e?.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
// Build unit lookup for downstream owner/ledger resolution
|
||||
const { data: allUnits } = await supabase.from("units").select("id, buildium_unit_id").not("buildium_unit_id", "is", null);
|
||||
const unitByBuildium = new Map<string, string>();
|
||||
for (const u of allUnits || []) unitByBuildium.set(String(u.buildium_unit_id), u.id);
|
||||
|
||||
// ---- 2. OWNERS ----
|
||||
for (const row of byKind.owner) {
|
||||
try {
|
||||
const p = { ...(row.payload || {}) };
|
||||
// Resolve unit_id by buildium id if needed
|
||||
if (!p.unit_id && p._resolve_unit_buildium_id) {
|
||||
const localUnit = unitByBuildium.get(String(p._resolve_unit_buildium_id));
|
||||
if (localUnit) p.unit_id = localUnit;
|
||||
}
|
||||
const clean = stripPrivate(p);
|
||||
if (row.action === "update" && row.match_id) {
|
||||
const { error } = await supabase.from("owners").update(clean).eq("id", row.match_id);
|
||||
if (error) throw error;
|
||||
} else {
|
||||
const { error } = await supabase.from("owners").insert(clean);
|
||||
if (error) throw error;
|
||||
}
|
||||
await markApplied(row.id);
|
||||
} catch (e: any) {
|
||||
await markFailed(row.id, e?.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
// Build owner lookup
|
||||
const { data: allOwners } = await supabase.from("owners").select("id, buildium_owner_id, unit_id").not("buildium_owner_id", "is", null);
|
||||
const ownerByBuildium = new Map<string, { id: string; unit_id: string | null }>();
|
||||
for (const o of allOwners || []) ownerByBuildium.set(String(o.buildium_owner_id), { id: o.id, unit_id: o.unit_id });
|
||||
|
||||
// ---- 3. GL ACCOUNTS (parents first) ----
|
||||
const glRows = byKind.gl_account.slice().sort((a, b) => {
|
||||
const ap = a.payload?._parent_buildium_id ? 1 : 0;
|
||||
const bp = b.payload?._parent_buildium_id ? 1 : 0;
|
||||
return ap - bp;
|
||||
});
|
||||
// Need a per-association lookup of account_number -> id for parent linkage
|
||||
const acctNumToIdByAssoc = new Map<string, Map<string, string>>();
|
||||
async function getAcctMap(assocId: string) {
|
||||
let m = acctNumToIdByAssoc.get(assocId);
|
||||
if (!m) {
|
||||
const { data } = await supabase.from("chart_of_accounts").select("id, account_number").eq("association_id", assocId);
|
||||
m = new Map<string, string>();
|
||||
for (const r of data || []) m.set(String(r.account_number), r.id);
|
||||
acctNumToIdByAssoc.set(assocId, m);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
for (const row of glRows) {
|
||||
try {
|
||||
const p = { ...(row.payload || {}) };
|
||||
if (p._parent_buildium_id && row.association_id) {
|
||||
const m = await getAcctMap(row.association_id);
|
||||
const parentId = m.get(String(p._parent_buildium_id));
|
||||
if (parentId) p.parent_account_id = parentId;
|
||||
}
|
||||
const clean = stripPrivate(p);
|
||||
if (row.action === "update" && row.match_id) {
|
||||
const { error } = await supabase.from("chart_of_accounts").update(clean).eq("id", row.match_id);
|
||||
if (error) throw error;
|
||||
} else {
|
||||
const { data: ins, error } = await supabase.from("chart_of_accounts").upsert(clean, { onConflict: "association_id, account_number" }).select("id, account_number").single();
|
||||
if (error) throw error;
|
||||
if (row.association_id && ins) {
|
||||
const m = await getAcctMap(row.association_id);
|
||||
m.set(String(ins.account_number), ins.id);
|
||||
}
|
||||
}
|
||||
await markApplied(row.id);
|
||||
} catch (e: any) {
|
||||
await markFailed(row.id, e?.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 4. LEDGER ENTRIES ----
|
||||
for (const row of byKind.ledger_entry) {
|
||||
try {
|
||||
const p = { ...(row.payload || {}) };
|
||||
if (!p.unit_id && p._resolve_unit_buildium_id) {
|
||||
const u = unitByBuildium.get(String(p._resolve_unit_buildium_id));
|
||||
if (u) p.unit_id = u;
|
||||
}
|
||||
if (!p.owner_id && Array.isArray(p._resolve_owner_buildium_ids)) {
|
||||
for (const boid of p._resolve_owner_buildium_ids) {
|
||||
const o = ownerByBuildium.get(String(boid));
|
||||
if (o) { p.owner_id = o.id; break; }
|
||||
}
|
||||
}
|
||||
// Fallback: any owner attached to this unit
|
||||
if (!p.owner_id && p.unit_id) {
|
||||
const { data: anyOwner } = await supabase.from("owners").select("id").eq("unit_id", p.unit_id).limit(1).maybeSingle();
|
||||
if (anyOwner) p.owner_id = anyOwner.id;
|
||||
}
|
||||
if (!p.unit_id || !p.owner_id) {
|
||||
throw new Error("Could not resolve unit/owner for ledger entry");
|
||||
}
|
||||
const clean = stripPrivate(p);
|
||||
// Skip if a buildium ref with same unit_id+reference_id already exists
|
||||
const { data: dupe } = await supabase
|
||||
.from("owner_ledger_entries")
|
||||
.select("id").eq("unit_id", clean.unit_id).eq("reference_type", "buildium").eq("reference_id", clean.reference_id)
|
||||
.maybeSingle();
|
||||
if (dupe) {
|
||||
await markApplied(row.id);
|
||||
continue;
|
||||
}
|
||||
const { error } = await supabase.from("owner_ledger_entries").insert(clean);
|
||||
if (error) throw error;
|
||||
await markApplied(row.id);
|
||||
} catch (e: any) {
|
||||
await markFailed(row.id, e?.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 5. ARC APPLICATIONS ----
|
||||
const buildiumClientId = Deno.env.get("BUILDIUM_API_KEY") ?? "";
|
||||
const buildiumClientSecret = Deno.env.get("BUILDIUM_API_SECRET") ?? "";
|
||||
|
||||
for (const row of byKind.arc_application || []) {
|
||||
try {
|
||||
const p = { ...(row.payload || {}) };
|
||||
if (!p.unit_id && p._resolve_unit_buildium_id) {
|
||||
const u = unitByBuildium.get(String(p._resolve_unit_buildium_id));
|
||||
if (u) p.unit_id = u;
|
||||
}
|
||||
if (!p.owner_id && p._resolve_owner_buildium_id) {
|
||||
const o = ownerByBuildium.get(String(p._resolve_owner_buildium_id));
|
||||
if (o) p.owner_id = o.id;
|
||||
}
|
||||
// Fallback: pick any owner on the unit
|
||||
if (!p.owner_id && p.unit_id) {
|
||||
const { data: anyOwner } = await supabase.from("owners").select("id").eq("unit_id", p.unit_id).limit(1).maybeSingle();
|
||||
if (anyOwner) p.owner_id = anyOwner.id;
|
||||
}
|
||||
|
||||
const files: Array<{ id: string; name: string }> = Array.isArray(p._arc_files) ? p._arc_files : [];
|
||||
const buildiumAssocId: string | null = p._arc_buildium_association_id || null;
|
||||
const buildiumArcId: string | null = p.buildium_arc_request_id || null;
|
||||
const decisionNotes: string | null = p.decision_notes || null;
|
||||
const reviewDate: string | null = p.review_date || null;
|
||||
const deciderName: string | null = p._arc_decider_name || null;
|
||||
const deciderDate: string | null = p._arc_decider_date || null;
|
||||
|
||||
const clean = stripPrivate(p);
|
||||
|
||||
let appId: string | null = null;
|
||||
if (row.action === "update" && row.match_id) {
|
||||
const { error } = await supabase.from("arc_applications").update(clean).eq("id", row.match_id);
|
||||
if (error) throw error;
|
||||
appId = row.match_id;
|
||||
} else {
|
||||
const { data: ins, error } = await supabase
|
||||
.from("arc_applications")
|
||||
.insert(clean)
|
||||
.select("id")
|
||||
.single();
|
||||
if (error) throw error;
|
||||
appId = ins.id;
|
||||
}
|
||||
|
||||
// Seed a system comment with the Buildium decision (since comments/voters aren't exposed via API)
|
||||
if (appId && (decisionNotes || deciderName)) {
|
||||
const { data: existingComment } = await supabase
|
||||
.from("arc_application_comments")
|
||||
.select("id")
|
||||
.eq("application_id", appId)
|
||||
.is("user_id", null)
|
||||
.ilike("comment", "%[Imported from Buildium]%")
|
||||
.maybeSingle();
|
||||
if (!existingComment) {
|
||||
const seed =
|
||||
`[Imported from Buildium]\n` +
|
||||
(deciderName ? `Decision by: ${deciderName}${deciderDate ? ` on ${deciderDate}` : ""}\n` : "") +
|
||||
(reviewDate && !deciderDate ? `Decision date: ${reviewDate}\n` : "") +
|
||||
(decisionNotes ? `Decision notes: ${decisionNotes}` : "");
|
||||
await supabase.from("arc_application_comments").insert({
|
||||
application_id: appId,
|
||||
user_id: null,
|
||||
comment: seed.trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Download attached files from Buildium and upload into the arc-files bucket
|
||||
if (appId && files.length > 0 && buildiumClientId && buildiumClientSecret && buildiumAssocId && buildiumArcId) {
|
||||
for (const f of files) {
|
||||
try {
|
||||
if (!f.id) continue;
|
||||
// Buildium uses a presigned download endpoint
|
||||
// Buildium uses the global ownership-accounts ARC path; download endpoint is "downloadrequests" (plural) and POST
|
||||
const dlRes = await fetch(
|
||||
`${BUILDIUM_BASE}/v1/associations/ownershipaccounts/architecturalrequests/${buildiumArcId}/files/${f.id}/downloadrequests`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-buildium-client-id": buildiumClientId,
|
||||
"x-buildium-client-secret": buildiumClientSecret,
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!dlRes.ok) {
|
||||
console.warn(`ARC file presign ${f.id} failed: ${dlRes.status}`);
|
||||
continue;
|
||||
}
|
||||
const dl: any = await dlRes.json().catch(() => ({}));
|
||||
const url: string | undefined = dl?.DownloadUrl || dl?.Url || dl?.url;
|
||||
if (!url) continue;
|
||||
const fileRes = await fetch(url);
|
||||
if (!fileRes.ok) continue;
|
||||
const buf = await fileRes.arrayBuffer();
|
||||
const safeName = (f.name || `buildium-${f.id}`).replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
const storagePath = `${clean.association_id}/${appId}/buildium-${f.id}-${safeName}`;
|
||||
await supabase.storage.from("arc-files").upload(storagePath, buf, {
|
||||
contentType: fileRes.headers.get("content-type") || "application/octet-stream",
|
||||
upsert: true,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(`ARC file copy failed for ${f.id}: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await markApplied(row.id);
|
||||
} catch (e: any) {
|
||||
await markFailed(row.id, e?.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ success: true, applied, failed }), {
|
||||
status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error("buildium-import-apply error", e);
|
||||
return new Response(JSON.stringify({ error: e?.message || String(e) }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,592 @@
|
||||
// Buildium Import Stage — fetches data from Buildium and writes a review batch
|
||||
// into public.buildium_import_staging instead of touching live tables.
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
const BUILDIUM_BASE = "https://api.buildium.com";
|
||||
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function buildiumFetch(path: string, clientId: string, clientSecret: string, params?: Record<string, string>) {
|
||||
const url = new URL(`${BUILDIUM_BASE}${path}`);
|
||||
if (params) Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { "x-buildium-client-id": clientId, "x-buildium-client-secret": clientSecret, Accept: "application/json" },
|
||||
});
|
||||
if (res.ok) return res.json();
|
||||
const text = await res.text();
|
||||
if ((res.status === 429 || res.status >= 500) && attempt < 3) {
|
||||
const ra = Number(res.headers.get("Retry-After") ?? "");
|
||||
await wait(Number.isFinite(ra) && ra > 0 ? ra * 1000 : 600 * Math.pow(2, attempt));
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Buildium ${path} [${res.status}]: ${text}`);
|
||||
}
|
||||
throw new Error(`Buildium ${path} failed after retries`);
|
||||
}
|
||||
|
||||
async function buildiumFetchAll(path: string, clientId: string, clientSecret: string, extra?: Record<string, string>) {
|
||||
const all: any[] = [];
|
||||
let offset = 0;
|
||||
const limit = 50;
|
||||
while (true) {
|
||||
const page = await buildiumFetch(path, clientId, clientSecret, { ...extra, offset: String(offset), limit: String(limit) });
|
||||
if (!Array.isArray(page) || page.length === 0) break;
|
||||
all.push(...page);
|
||||
if (page.length < limit) break;
|
||||
offset += limit;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
const norm = (v: unknown) => String(v ?? "").toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim();
|
||||
const normId = (v: unknown) => {
|
||||
if (v === null || v === undefined) return null;
|
||||
const s = String(v).trim();
|
||||
return s.length > 0 ? s : null;
|
||||
};
|
||||
|
||||
function formatAddress(a: any): string {
|
||||
if (!a) return "";
|
||||
const parts = [a.AddressLine1, a.AddressLine2, a.AddressLine3, a.City, a.State, a.PostalCode || a.ZipCode].filter(Boolean);
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function mapGLAccountType(type: string | null | undefined): string {
|
||||
const t = String(type || "").toLowerCase();
|
||||
if (t.includes("asset")) return "asset";
|
||||
if (t.includes("liabilit")) return "liability";
|
||||
if (t.includes("equity")) return "equity";
|
||||
if (t.includes("income") || t.includes("revenue")) return "income";
|
||||
if (t.includes("expense")) return "expense";
|
||||
return "expense";
|
||||
}
|
||||
|
||||
function diff(existing: any, incoming: Record<string, any>): Record<string, { from: any; to: any }> {
|
||||
const out: Record<string, { from: any; to: any }> = {};
|
||||
for (const [k, v] of Object.entries(incoming)) {
|
||||
if (v === null || v === undefined || v === "") continue;
|
||||
const cur = existing?.[k];
|
||||
if (cur !== v && !(cur == null && v == null)) {
|
||||
out[k] = { from: cur ?? null, to: v };
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||
|
||||
try {
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
|
||||
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
|
||||
let userId: string | null = null;
|
||||
try {
|
||||
const payload = token.split(".")[1];
|
||||
const padded = payload.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(payload.length / 4) * 4, "=");
|
||||
userId = JSON.parse(atob(padded))?.sub ?? null;
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
const auth = createClient(supabaseUrl, anonKey, { global: { headers: { Authorization: authHeader } } });
|
||||
const { data: roles } = await auth.from("user_roles").select("role").eq("user_id", userId);
|
||||
const isStaff = (roles || []).some((r: any) => r.role === "admin" || r.role === "manager");
|
||||
if (!isStaff) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, serviceKey);
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const kinds: string[] = Array.isArray(body.kinds) && body.kinds.length > 0 ? body.kinds : ["unit", "owner", "gl_account", "ledger_entry"];
|
||||
const includeArcFiles = body.includeArcFiles !== false;
|
||||
const selectedAssociationIds: string[] = Array.isArray(body.selectedAssociationIds) ? body.selectedAssociationIds.filter((s: any) => typeof s === "string" && s) : [];
|
||||
const dateFrom = typeof body.dateFrom === "string" ? body.dateFrom : null;
|
||||
const dateTo = typeof body.dateTo === "string" ? body.dateTo : null;
|
||||
const unitFilterId = typeof body.unitId === "string" && body.unitId ? body.unitId : null;
|
||||
|
||||
const clientId = Deno.env.get("BUILDIUM_API_KEY") ?? "";
|
||||
const clientSecret = Deno.env.get("BUILDIUM_API_SECRET") ?? "";
|
||||
if (!clientId || !clientSecret) {
|
||||
return new Response(JSON.stringify({ error: "Buildium API credentials not configured" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
let scopedAssocIds = [...selectedAssociationIds];
|
||||
if (unitFilterId && scopedAssocIds.length === 0) {
|
||||
const { data: u } = await supabase.from("units").select("association_id").eq("id", unitFilterId).maybeSingle();
|
||||
if (u?.association_id) scopedAssocIds = [u.association_id];
|
||||
}
|
||||
const isSelected = (id: string | null) => scopedAssocIds.length === 0 || (!!id && scopedAssocIds.includes(id));
|
||||
|
||||
const { data: assocRows } = await supabase.from("associations").select("id, name");
|
||||
const assocByName = new Map<string, any>();
|
||||
for (const a of assocRows || []) assocByName.set(norm(a.name), a);
|
||||
|
||||
const buildiumAssocs = await buildiumFetchAll("/v1/associations", clientId, clientSecret);
|
||||
const bAssocIdToLocalId = new Map<string, string>();
|
||||
for (const ba of buildiumAssocs) {
|
||||
const local = assocByName.get(norm(ba.Name));
|
||||
if (local) bAssocIdToLocalId.set(String(ba.Id), local.id);
|
||||
}
|
||||
|
||||
const batchId = crypto.randomUUID();
|
||||
const stageRows: any[] = [];
|
||||
|
||||
function stage(kind: string, action: string, association_id: string | null, external_id: string | null, summary: string, payload: any, match_id: string | null = null, diffObj: any = null) {
|
||||
stageRows.push({
|
||||
batch_id: batchId,
|
||||
association_id,
|
||||
kind,
|
||||
action,
|
||||
external_id,
|
||||
match_id,
|
||||
summary,
|
||||
payload,
|
||||
diff: diffObj,
|
||||
status: "pending",
|
||||
created_by: userId,
|
||||
});
|
||||
}
|
||||
|
||||
let buildiumUnits: any[] = [];
|
||||
if (kinds.includes("unit") || kinds.includes("owner") || kinds.includes("ledger_entry")) {
|
||||
buildiumUnits = await buildiumFetchAll("/v1/associations/units", clientId, clientSecret);
|
||||
}
|
||||
|
||||
const { data: unitRows } = await supabase
|
||||
.from("units")
|
||||
.select("id, association_id, unit_number, address, buildium_unit_id");
|
||||
const unitByBuildiumId = new Map<string, any>();
|
||||
const unitByAssocAddr = new Map<string, any>();
|
||||
const unitByAssocNumber = new Map<string, any>();
|
||||
for (const u of unitRows || []) {
|
||||
if (u.buildium_unit_id) unitByBuildiumId.set(String(u.buildium_unit_id), u);
|
||||
if (u.address) unitByAssocAddr.set(`${u.association_id}|${norm(u.address)}`, u);
|
||||
if (u.unit_number) unitByAssocNumber.set(`${u.association_id}|${norm(u.unit_number)}`, u);
|
||||
}
|
||||
|
||||
if (kinds.includes("unit")) {
|
||||
for (const bu of buildiumUnits) {
|
||||
const assocLocalId = bAssocIdToLocalId.get(String(bu.AssociationId)) || null;
|
||||
if (!assocLocalId || !isSelected(assocLocalId)) continue;
|
||||
const buildium_unit_id = String(bu.Id);
|
||||
const unit_number = String(bu.UnitNumber || bu.UnitName || "").trim() || null;
|
||||
const address = formatAddress(bu.Address) || null;
|
||||
const incoming = { association_id: assocLocalId, unit_number, address, buildium_unit_id, status: "active" };
|
||||
|
||||
const matchById = unitByBuildiumId.get(buildium_unit_id);
|
||||
const matchByAddr = !matchById && address ? unitByAssocAddr.get(`${assocLocalId}|${norm(address)}`) : null;
|
||||
const matchByNum = !matchById && !matchByAddr && unit_number ? unitByAssocNumber.get(`${assocLocalId}|${norm(unit_number)}`) : null;
|
||||
const match = matchById || matchByAddr || matchByNum || null;
|
||||
|
||||
if (match) {
|
||||
const d = diff(match, incoming);
|
||||
if (Object.keys(d).length === 0) continue;
|
||||
stage("unit", "update", assocLocalId, buildium_unit_id, `Unit ${unit_number || address || buildium_unit_id}`, incoming, match.id, d);
|
||||
} else {
|
||||
stage("unit", "create", assocLocalId, buildium_unit_id, `Unit ${unit_number || address || buildium_unit_id}`, incoming, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (kinds.includes("owner")) {
|
||||
const owners = await buildiumFetchAll("/v1/associations/owners", clientId, clientSecret);
|
||||
const { data: ownerRows } = await supabase
|
||||
.from("owners")
|
||||
.select("id, association_id, first_name, last_name, property_address, mailing_address, email, phone, buildium_owner_id, unit_id, status");
|
||||
const ownerByBuildiumId = new Map<string, any>();
|
||||
const ownerByKey = new Map<string, any>();
|
||||
for (const o of ownerRows || []) {
|
||||
if (o.buildium_owner_id) ownerByBuildiumId.set(String(o.buildium_owner_id), o);
|
||||
ownerByKey.set(`${o.association_id}|${norm(o.first_name)}|${norm(o.last_name)}|${norm(o.property_address)}`, o);
|
||||
}
|
||||
|
||||
const ownershipAccounts = await buildiumFetchAll("/v1/associations/ownershipaccounts", clientId, clientSecret);
|
||||
const acctByOwnerId = new Map<string, any[]>();
|
||||
for (const acct of ownershipAccounts) {
|
||||
const ids: string[] = [];
|
||||
if (Array.isArray(acct.AssociationOwnerIds)) for (const id of acct.AssociationOwnerIds) ids.push(String(id));
|
||||
if (acct.AssociationOwnerId) ids.push(String(acct.AssociationOwnerId));
|
||||
for (const id of ids) {
|
||||
if (!acctByOwnerId.has(id)) acctByOwnerId.set(id, []);
|
||||
acctByOwnerId.get(id)!.push(acct);
|
||||
}
|
||||
}
|
||||
|
||||
for (const o of owners) {
|
||||
const buildium_owner_id = String(o.Id);
|
||||
const first_name = (o.FirstName || "").trim() || "Unknown";
|
||||
const last_name = (o.LastName || "").trim() || "Owner";
|
||||
const property_address = formatAddress(o.PrimaryAddress) || "N/A";
|
||||
const mailing_address = formatAddress(o.AlternateAddress) || null;
|
||||
let assocLocalId = bAssocIdToLocalId.get(String(o.AssociationId)) || null;
|
||||
|
||||
const linked = acctByOwnerId.get(buildium_owner_id) || [];
|
||||
if (!assocLocalId) {
|
||||
for (const acct of linked) {
|
||||
const id = bAssocIdToLocalId.get(String(acct.AssociationId));
|
||||
if (id) { assocLocalId = id; break; }
|
||||
}
|
||||
}
|
||||
if (!assocLocalId || !isSelected(assocLocalId)) continue;
|
||||
|
||||
let unitLocalId: string | null = null;
|
||||
let unitBuildiumIdForResolve: string | null = null;
|
||||
for (const acct of linked) {
|
||||
const bUid = normId(acct.UnitId);
|
||||
if (bUid) {
|
||||
unitBuildiumIdForResolve = bUid;
|
||||
const u = unitByBuildiumId.get(bUid);
|
||||
if (u) { unitLocalId = u.id; break; }
|
||||
}
|
||||
}
|
||||
|
||||
const isCurrent = linked.some((a: any) => {
|
||||
const s = String(a.Status || "").toLowerCase();
|
||||
return s === "" || s === "active" || s === "current";
|
||||
});
|
||||
const status = isCurrent ? "active" : "archived";
|
||||
|
||||
const incoming = {
|
||||
association_id: assocLocalId,
|
||||
first_name, last_name,
|
||||
property_address, mailing_address,
|
||||
email: o.Email || null,
|
||||
phone: o.PhoneNumbers?.[0]?.Number || null,
|
||||
buildium_owner_id,
|
||||
unit_id: unitLocalId,
|
||||
status,
|
||||
_resolve_unit_buildium_id: unitBuildiumIdForResolve,
|
||||
};
|
||||
|
||||
const match = ownerByBuildiumId.get(buildium_owner_id) || ownerByKey.get(`${assocLocalId}|${norm(first_name)}|${norm(last_name)}|${norm(property_address)}`) || null;
|
||||
|
||||
if (match) {
|
||||
const d = diff(match, { ...incoming, _resolve_unit_buildium_id: undefined });
|
||||
if (Object.keys(d).length === 0) continue;
|
||||
stage("owner", "update", assocLocalId, buildium_owner_id, `${first_name} ${last_name}`, incoming, match.id, d);
|
||||
} else {
|
||||
stage("owner", "create", assocLocalId, buildium_owner_id, `${first_name} ${last_name}${property_address && property_address !== "N/A" ? ` — ${property_address}` : ""}`, incoming, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (kinds.includes("gl_account")) {
|
||||
const gls = await buildiumFetchAll("/v1/glaccounts", clientId, clientSecret);
|
||||
for (const a of assocRows || []) {
|
||||
if (!isSelected(a.id)) continue;
|
||||
const { data: existing } = await supabase
|
||||
.from("chart_of_accounts")
|
||||
.select("id, association_id, account_number, account_name, account_type, description, is_active")
|
||||
.eq("association_id", a.id);
|
||||
const byNum = new Map<string, any>();
|
||||
for (const e of existing || []) byNum.set(String(e.account_number), e);
|
||||
for (const gl of gls) {
|
||||
const account_number = String(gl.Id || gl.AccountNumber || "");
|
||||
if (!account_number) continue;
|
||||
const incoming = {
|
||||
association_id: a.id,
|
||||
account_number,
|
||||
account_name: gl.Name || "Unknown",
|
||||
account_type: mapGLAccountType(gl.Type || gl.AccountType),
|
||||
description: gl.Description || null,
|
||||
is_active: gl.IsActive !== false,
|
||||
_parent_buildium_id: gl.ParentGLAccountId ? String(gl.ParentGLAccountId) : null,
|
||||
};
|
||||
const match = byNum.get(account_number);
|
||||
if (match) {
|
||||
const d = diff(match, { ...incoming, _parent_buildium_id: undefined });
|
||||
if (Object.keys(d).length === 0) continue;
|
||||
stage("gl_account", "update", a.id, account_number, `${account_number} — ${incoming.account_name} (${a.name})`, incoming, match.id, d);
|
||||
} else {
|
||||
stage("gl_account", "create", a.id, account_number, `${account_number} — ${incoming.account_name} (${a.name})`, incoming, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (kinds.includes("ledger_entry")) {
|
||||
const ownershipAccounts = await buildiumFetchAll("/v1/associations/ownershipaccounts", clientId, clientSecret);
|
||||
|
||||
const { data: ownerAll } = await supabase.from("owners").select("id, buildium_owner_id, unit_id, association_id").not("buildium_owner_id", "is", null);
|
||||
const ownerByBuildium = new Map<string, any>();
|
||||
for (const o of ownerAll || []) ownerByBuildium.set(String(o.buildium_owner_id), o);
|
||||
|
||||
const existingRefs = new Set<string>();
|
||||
const { data: existingLedger } = await supabase
|
||||
.from("owner_ledger_entries")
|
||||
.select("unit_id, reference_id")
|
||||
.eq("reference_type", "buildium")
|
||||
.not("reference_id", "is", null)
|
||||
.limit(50000);
|
||||
for (const r of existingLedger || []) if (r.unit_id && r.reference_id) existingRefs.add(`${r.unit_id}|${r.reference_id}`);
|
||||
|
||||
for (const acct of ownershipAccounts) {
|
||||
const assocLocalId = bAssocIdToLocalId.get(String(acct.AssociationId)) || null;
|
||||
if (!assocLocalId || !isSelected(assocLocalId)) continue;
|
||||
const buildiumUnitId = normId(acct.UnitId);
|
||||
if (!buildiumUnitId) continue;
|
||||
const unit = unitByBuildiumId.get(buildiumUnitId) || null;
|
||||
if (!unit) continue;
|
||||
if (unitFilterId && unit.id !== unitFilterId) continue;
|
||||
|
||||
const txnParams: Record<string, string> = {};
|
||||
if (dateFrom) txnParams.transactiondatefrom = dateFrom;
|
||||
if (dateTo) txnParams.transactiondateto = dateTo;
|
||||
let entries: any[] = [];
|
||||
try {
|
||||
entries = await buildiumFetchAll(`/v1/associations/ownershipaccounts/${acct.Id}/transactions`, clientId, clientSecret, Object.keys(txnParams).length ? txnParams : undefined);
|
||||
} catch (e) {
|
||||
console.warn(`Ledger fetch ${acct.Id}: ${e}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const e of entries) {
|
||||
const refId = String(e.Id || "");
|
||||
if (!refId) continue;
|
||||
const amount = Number(e.TotalAmount ?? e.Amount ?? 0);
|
||||
if (amount === 0) continue;
|
||||
const txType = String(e.TransactionType || "");
|
||||
const txDate = e.Date ? String(e.Date).split("T")[0] : new Date().toISOString().split("T")[0];
|
||||
|
||||
if (/prepayment/i.test(txType) || /prepayment/i.test(String(e.Description || e.Memo || ""))) {
|
||||
if (!/sirs reserve prepayment/i.test(String(e.Description || e.Memo || ""))) continue;
|
||||
}
|
||||
|
||||
const isPayment = amount < 0 || /payment|credit|deposit|refund/i.test(txType);
|
||||
// POLICY: Only PULL payments from Buildium. Charges originate locally.
|
||||
if (!isPayment) continue;
|
||||
const debit = isPayment ? 0 : Math.abs(amount);
|
||||
const credit = isPayment ? Math.abs(amount) : 0;
|
||||
const desc = String(e.Description || e.Memo || e.Journal?.Memo || txType || "Buildium entry").slice(0, 500);
|
||||
|
||||
if (existingRefs.has(`${unit.id}|${refId}`)) continue;
|
||||
|
||||
let ownerLocalId: string | null = null;
|
||||
const buildiumOwnerIds: string[] = [];
|
||||
if (Array.isArray(acct.AssociationOwnerIds)) for (const id of acct.AssociationOwnerIds) buildiumOwnerIds.push(String(id));
|
||||
if (acct.AssociationOwnerId) buildiumOwnerIds.push(String(acct.AssociationOwnerId));
|
||||
for (const boid of buildiumOwnerIds) {
|
||||
const o = ownerByBuildium.get(boid);
|
||||
if (o) { ownerLocalId = o.id; break; }
|
||||
}
|
||||
|
||||
const incoming = {
|
||||
association_id: assocLocalId,
|
||||
unit_id: unit.id,
|
||||
owner_id: ownerLocalId,
|
||||
date: txDate,
|
||||
transaction_type: txType,
|
||||
description: desc,
|
||||
debit, credit,
|
||||
reference_id: refId,
|
||||
reference_type: "buildium",
|
||||
_resolve_unit_buildium_id: buildiumUnitId,
|
||||
_resolve_owner_buildium_ids: buildiumOwnerIds,
|
||||
};
|
||||
|
||||
stage(
|
||||
"ledger_entry",
|
||||
"create",
|
||||
assocLocalId,
|
||||
refId,
|
||||
`${txDate} • ${desc.slice(0, 80)} • ${debit > 0 ? `-$${debit.toFixed(2)}` : `+$${credit.toFixed(2)}`}`,
|
||||
incoming,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ARC APPLICATIONS ----
|
||||
if (kinds.includes("arc_application")) {
|
||||
// Map Buildium Status + Decision to local status.
|
||||
// Buildium fields: Status = New | InProgress | Closed; Decision = Pending | Approved | Denied
|
||||
function mapArcStatus(status: string | null | undefined, decision: string | null | undefined): string {
|
||||
const d = String(decision || "").toLowerCase();
|
||||
if (d.includes("approve")) return "approved";
|
||||
if (d.includes("den") || d.includes("reject")) return "denied";
|
||||
const s = String(status || "").toLowerCase();
|
||||
if (s.includes("withdraw") || s.includes("cancel")) return "withdrawn";
|
||||
if (s.includes("progress") || s.includes("review")) return "under_review";
|
||||
if (s.includes("closed")) return "approved";
|
||||
return "submitted";
|
||||
}
|
||||
|
||||
// Existing ARC apps for matching by buildium id
|
||||
const { data: existingArc } = await supabase
|
||||
.from("arc_applications")
|
||||
.select("id, association_id, buildium_arc_request_id, title, description, status, decision_notes, review_date, submitted_date, unit_id, owner_id");
|
||||
const arcByBuildium = new Map<string, any>();
|
||||
for (const a of existingArc || []) {
|
||||
if (a.buildium_arc_request_id) arcByBuildium.set(`${a.association_id}|${a.buildium_arc_request_id}`, a);
|
||||
}
|
||||
|
||||
// Owner lookup by buildium id
|
||||
const { data: ownersAll } = await supabase
|
||||
.from("owners")
|
||||
.select("id, unit_id, association_id, buildium_owner_id")
|
||||
.not("buildium_owner_id", "is", null);
|
||||
const ownerByBuildiumLocal = new Map<string, any>();
|
||||
for (const o of ownersAll || []) ownerByBuildiumLocal.set(String(o.buildium_owner_id), o);
|
||||
|
||||
for (const ba of buildiumAssocs) {
|
||||
const assocLocalId = bAssocIdToLocalId.get(String(ba.Id));
|
||||
if (!assocLocalId || !isSelected(assocLocalId)) continue;
|
||||
|
||||
let arcRequests: any[] = [];
|
||||
try {
|
||||
arcRequests = await buildiumFetchAll(
|
||||
`/v1/associations/ownershipaccounts/architecturalrequests`,
|
||||
clientId,
|
||||
clientSecret,
|
||||
{ associationids: String(ba.Id) },
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(`ARC fetch for association ${ba.Id} failed: ${e}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const r of arcRequests) {
|
||||
const buildiumArcId = String(r.Id);
|
||||
// Buildium ARC payload doesn't expose UnitId — resolve unit via the owner's unit later.
|
||||
const buildiumUnitId = normId(r.UnitId);
|
||||
const buildiumOwnerId = normId(r.OwnershipAccountId || r.AssociationOwnerId);
|
||||
const localOwner = buildiumOwnerId ? ownerByBuildiumLocal.get(buildiumOwnerId) : null;
|
||||
const localUnit = buildiumUnitId
|
||||
? unitByBuildiumId.get(buildiumUnitId)
|
||||
: (localOwner?.unit_id ? { id: localOwner.unit_id } : null);
|
||||
|
||||
// Buildium uses `Name` for the request title and does not expose a description on this resource.
|
||||
const rawName = String(r.Name || r.Title || r.Subject || "").trim();
|
||||
const title = rawName || `Architectural Request #${buildiumArcId}`;
|
||||
const description = String(r.Description || "").trim();
|
||||
|
||||
const submittedDate = r.SubmittedDateTime
|
||||
? String(r.SubmittedDateTime).split("T")[0]
|
||||
: (r.SubmittedDate ? String(r.SubmittedDate).split("T")[0] : null);
|
||||
const decisionDate = r.DecisionDateTime
|
||||
? String(r.DecisionDateTime).split("T")[0]
|
||||
: (r.DecisionDate ? String(r.DecisionDate).split("T")[0] : null);
|
||||
const decisionNotes = r.DecisionDescription
|
||||
? String(r.DecisionDescription)
|
||||
: (r.Decision && !/pending/i.test(String(r.Decision)) ? `Decision: ${r.Decision}` : null);
|
||||
const status = mapArcStatus(r.Status, r.Decision);
|
||||
|
||||
// Buildium exposes the user who last touched the request — use as best-available "decider"
|
||||
const decider = r.LastUpdatedByUser || r.UpdatedByUser || null;
|
||||
const deciderName = decider
|
||||
? [decider.FirstName, decider.LastName].filter(Boolean).join(" ").trim() || null
|
||||
: null;
|
||||
const deciderDate = (r.LastUpdatedDateTime || decider?.UpdatedDateTime || decisionDate || "")
|
||||
?.toString()
|
||||
.split("T")[0] || null;
|
||||
|
||||
// Optional file pull
|
||||
let files: Array<{ id: string; name: string; download_url: string | null }> = [];
|
||||
if (includeArcFiles) {
|
||||
try {
|
||||
const fileList = await buildiumFetchAll(
|
||||
`/v1/associations/ownershipaccounts/architecturalrequests/${buildiumArcId}/files`,
|
||||
clientId,
|
||||
clientSecret,
|
||||
);
|
||||
files = (fileList || []).map((f: any) => ({
|
||||
id: String(f.Id || f.FileId || ""),
|
||||
name: String(f.Title || f.FileName || f.Name || f.PhysicalFileName || `file-${f.Id}`),
|
||||
download_url: null,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.warn(`ARC files fetch failed for ${buildiumArcId}: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
const incoming: Record<string, any> = {
|
||||
association_id: assocLocalId,
|
||||
unit_id: localUnit?.id || null,
|
||||
owner_id: localOwner?.id || null,
|
||||
title,
|
||||
description: description || null,
|
||||
project_type: r.ProjectType || r.Type || null,
|
||||
status,
|
||||
submitted_date: submittedDate,
|
||||
review_date: decisionDate,
|
||||
decision_notes: decisionNotes,
|
||||
buildium_arc_request_id: buildiumArcId,
|
||||
_resolve_unit_buildium_id: buildiumUnitId,
|
||||
_resolve_owner_buildium_id: buildiumOwnerId,
|
||||
_arc_files: files,
|
||||
_arc_buildium_association_id: String(ba.Id),
|
||||
_arc_decider_name: deciderName,
|
||||
_arc_decider_date: deciderDate,
|
||||
};
|
||||
|
||||
const match = arcByBuildium.get(`${assocLocalId}|${buildiumArcId}`);
|
||||
if (match) {
|
||||
const d = diff(match, {
|
||||
status: incoming.status,
|
||||
decision_notes: incoming.decision_notes,
|
||||
review_date: incoming.review_date,
|
||||
title: incoming.title,
|
||||
description: incoming.description,
|
||||
});
|
||||
if (Object.keys(d).length === 0 && (!includeArcFiles || files.length === 0)) continue;
|
||||
stage(
|
||||
"arc_application",
|
||||
"update",
|
||||
assocLocalId,
|
||||
buildiumArcId,
|
||||
`${title} — ${status}${files.length ? ` · ${files.length} file${files.length > 1 ? "s" : ""}` : ""}`,
|
||||
incoming,
|
||||
match.id,
|
||||
d,
|
||||
);
|
||||
} else {
|
||||
stage(
|
||||
"arc_application",
|
||||
"create",
|
||||
assocLocalId,
|
||||
buildiumArcId,
|
||||
`${title} — ${status}${files.length ? ` · ${files.length} file${files.length > 1 ? "s" : ""}` : ""}`,
|
||||
incoming,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let inserted = 0;
|
||||
for (let i = 0; i < stageRows.length; i += 500) {
|
||||
const slice = stageRows.slice(i, i + 500);
|
||||
const { error } = await supabase.from("buildium_import_staging").insert(slice);
|
||||
if (error) throw error;
|
||||
inserted += slice.length;
|
||||
}
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
for (const r of stageRows) counts[r.kind] = (counts[r.kind] || 0) + 1;
|
||||
|
||||
return new Response(JSON.stringify({ success: true, batch_id: batchId, inserted, counts }), {
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error("buildium-import-stage error", e);
|
||||
return new Response(JSON.stringify({ error: e?.message || String(e) }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,121 @@
|
||||
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",
|
||||
};
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, serviceKey);
|
||||
|
||||
const now = new Date();
|
||||
const in24h = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||
|
||||
// Find reminders due within 24 hours that haven't been notified recently
|
||||
const { data: reminders, error } = await supabase
|
||||
.from("reminders")
|
||||
.select("*")
|
||||
.neq("status", "completed")
|
||||
.lte("due_date", in24h.toISOString())
|
||||
.gte("due_date", now.toISOString());
|
||||
|
||||
if (error) throw error;
|
||||
if (!reminders || reminders.length === 0) {
|
||||
return new Response(JSON.stringify({ success: true, processed: 0 }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out recently notified (within last 12 hours)
|
||||
const twelveHoursAgo = new Date(now.getTime() - 12 * 60 * 60 * 1000);
|
||||
const dueReminders = reminders.filter((r: any) => {
|
||||
if (!r.last_notified_at) return true;
|
||||
return new Date(r.last_notified_at) < twelveHoursAgo;
|
||||
});
|
||||
|
||||
// Get active SMTP sender
|
||||
const { data: senders } = await supabase
|
||||
.from("email_senders")
|
||||
.select("*")
|
||||
.eq("is_active", true)
|
||||
.eq("verified", true)
|
||||
.limit(1);
|
||||
|
||||
const sender = senders?.[0];
|
||||
|
||||
let processed = 0;
|
||||
|
||||
for (const reminder of dueReminders) {
|
||||
const userId = reminder.assigned_to || reminder.created_by;
|
||||
if (!userId) continue;
|
||||
|
||||
// Create in-app notification
|
||||
await supabase.rpc("insert_notification", {
|
||||
p_user_id: userId,
|
||||
p_type: "reminder_due",
|
||||
p_title: "Reminder Due Soon",
|
||||
p_message: `"${reminder.title}" is due ${new Date(reminder.due_date).toLocaleString()}.`,
|
||||
p_related_item_type: "reminder",
|
||||
p_link: "/dashboard/settings/reminders",
|
||||
});
|
||||
|
||||
// Send email if SMTP is configured
|
||||
if (sender) {
|
||||
try {
|
||||
const { data: userData } = await supabase.auth.admin.getUserById(userId);
|
||||
if (userData?.user?.email) {
|
||||
const senderFrom = sender.sender_name
|
||||
? `${sender.sender_name} <${sender.email_address}>`
|
||||
: sender.email_address;
|
||||
|
||||
await supabase.functions.invoke("send-smtp-email", {
|
||||
body: {
|
||||
sender: {
|
||||
host: sender.smtp_host,
|
||||
port: sender.smtp_port,
|
||||
username: sender.smtp_username,
|
||||
password: sender.smtp_password,
|
||||
use_tls: sender.use_tls,
|
||||
use_ssl: sender.use_ssl,
|
||||
from: senderFrom,
|
||||
},
|
||||
recipient: [userData.user.email],
|
||||
subject: `Reminder Due: ${reminder.title}`,
|
||||
body: `<h3>Reminder Due Soon</h3><p>Your reminder "<strong>${reminder.title}</strong>" is due on ${new Date(reminder.due_date).toLocaleString()}.</p>${reminder.description ? `<p>${reminder.description}</p>` : ""}<p>Please log in to the portal to manage your reminders.</p>`,
|
||||
html: `<h3>Reminder Due Soon</h3><p>Your reminder "<strong>${reminder.title}</strong>" is due on ${new Date(reminder.due_date).toLocaleString()}.</p>${reminder.description ? `<p>${reminder.description}</p>` : ""}<p>Please log in to the portal to manage your reminders.</p>`,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (emailErr) {
|
||||
console.error(`Failed to send email for reminder ${reminder.id}:`, emailErr);
|
||||
}
|
||||
}
|
||||
|
||||
// Update last_notified_at
|
||||
await supabase
|
||||
.from("reminders")
|
||||
.update({ last_notified_at: now.toISOString() })
|
||||
.eq("id", reminder.id);
|
||||
|
||||
processed++;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ success: true, processed }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("check-reminder-emails error:", error);
|
||||
return new Response(JSON.stringify({ success: false, error: (error as Error).message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.3";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
// Called after SetupIntent succeeds to save enrollment
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser(token);
|
||||
if (authError || !user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const {
|
||||
setup_intent_id,
|
||||
association_id,
|
||||
owner_id,
|
||||
unit_id,
|
||||
customer_id,
|
||||
payment_method_type,
|
||||
} = body;
|
||||
|
||||
if (!setup_intent_id || !association_id || !customer_id) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing required fields" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Get Stripe mapping to retrieve the payment method from SetupIntent
|
||||
const { data: mapping } = await supabase
|
||||
.from("stripe_account_mappings")
|
||||
.select("stripe_secret_key")
|
||||
.eq("association_id", association_id)
|
||||
.eq("is_active", true)
|
||||
.maybeSingle();
|
||||
|
||||
if (!mapping?.stripe_secret_key) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "No Stripe configuration found" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Retrieve SetupIntent to get payment_method
|
||||
const siRes = await fetch(
|
||||
`https://api.stripe.com/v1/setup_intents/${setup_intent_id}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${mapping.stripe_secret_key}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const siData = await siRes.json();
|
||||
if (!siRes.ok || siData.status !== "succeeded") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "SetupIntent not successful",
|
||||
status: siData.status,
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const paymentMethodId = siData.payment_method;
|
||||
|
||||
// Deactivate any existing enrollment for this user+association
|
||||
await supabase
|
||||
.from("autopay_enrollments")
|
||||
.update({ is_active: false })
|
||||
.eq("association_id", association_id)
|
||||
.eq("enrolled_by", user.id)
|
||||
.eq("is_active", true);
|
||||
|
||||
// Insert new enrollment
|
||||
const { data: enrollment, error: insertError } = await supabase
|
||||
.from("autopay_enrollments")
|
||||
.insert({
|
||||
association_id,
|
||||
owner_id: owner_id || null,
|
||||
unit_id: unit_id || null,
|
||||
stripe_customer_id: customer_id,
|
||||
stripe_payment_method_id: paymentMethodId,
|
||||
payment_method_type: payment_method_type || "card",
|
||||
is_active: true,
|
||||
enrolled_by: user.id,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (insertError) {
|
||||
console.error("Insert error:", insertError);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Failed to save enrollment" }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ success: true, enrollment }), {
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
console.error("Error in confirm-autopay:", err);
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Internal server error";
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.3";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
const { booking_id, session_id } = await req.json();
|
||||
|
||||
if (!booking_id) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing booking_id" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch booking
|
||||
const { data: booking, error: bookingErr } = await supabase
|
||||
.from("amenity_bookings")
|
||||
.select("*")
|
||||
.eq("id", booking_id)
|
||||
.single();
|
||||
|
||||
if (bookingErr || !booking) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Booking not found" }),
|
||||
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Only process if still pending_payment
|
||||
if (booking.status !== "pending_payment") {
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, message: "Already processed" }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Update booking status to confirmed
|
||||
await supabase
|
||||
.from("amenity_bookings")
|
||||
.update({ status: "confirmed" })
|
||||
.eq("id", booking_id);
|
||||
|
||||
// Extract pin_id from notes
|
||||
let pinId: string | null = null;
|
||||
try {
|
||||
const notes = typeof booking.notes === "string" ? JSON.parse(booking.notes) : booking.notes;
|
||||
pinId = notes?.pin_id || null;
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Mark pin as rented
|
||||
if (pinId && booking.amenity_id) {
|
||||
const { data: amenity } = await supabase
|
||||
.from("amenities")
|
||||
.select("map_config")
|
||||
.eq("id", booking.amenity_id)
|
||||
.single();
|
||||
|
||||
if (amenity?.map_config?.pins) {
|
||||
const updatedPins = amenity.map_config.pins.map((pin: any) =>
|
||||
pin.id === pinId ? { ...pin, status: "rented" } : pin
|
||||
);
|
||||
|
||||
await supabase
|
||||
.from("amenities")
|
||||
.update({
|
||||
map_config: { ...amenity.map_config, pins: updatedPins },
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", booking.amenity_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Send confirmation email
|
||||
if (booking.guest_email) {
|
||||
try {
|
||||
const { data: amenityRow } = await supabase
|
||||
.from("amenities").select("name, association_id").eq("id", booking.amenity_id).single();
|
||||
const { data: assocRow } = amenityRow?.association_id
|
||||
? await supabase.from("associations").select("name").eq("id", amenityRow.association_id).single()
|
||||
: { data: null };
|
||||
const { data: pageRow } = amenityRow?.association_id
|
||||
? await supabase.from("association_public_pages").select("slug").eq("association_id", amenityRow.association_id).maybeSingle()
|
||||
: { data: null };
|
||||
const origin = req.headers.get("origin") || "https://avria.cloud";
|
||||
const dateObj = booking.booking_date ? new Date(`${booking.booking_date}T12:00:00`) : null;
|
||||
const formattedDate = dateObj
|
||||
? dateObj.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" })
|
||||
: "";
|
||||
await supabase.functions.invoke("send-transactional-email", {
|
||||
body: {
|
||||
templateName: "amenity-booking-confirmation",
|
||||
recipientEmail: booking.guest_email,
|
||||
idempotencyKey: `amenity-booking-confirmed-${booking.id}`,
|
||||
templateData: {
|
||||
guestName: booking.guest_name,
|
||||
amenityName: amenityRow?.name,
|
||||
associationName: (assocRow as any)?.name,
|
||||
bookingDate: formattedDate,
|
||||
startTime: booking.start_time || "",
|
||||
status: "confirmed",
|
||||
confirmationLink: `${origin}/booking/${booking.id}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to send confirmation email:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error in confirm-reservation-payment:", err);
|
||||
return new Response(
|
||||
JSON.stringify({ error: err.message || "Internal server error" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.3";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Verify the calling user
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser(token);
|
||||
if (authError || !user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const {
|
||||
amount_cents,
|
||||
association_id,
|
||||
owner_id,
|
||||
unit_id,
|
||||
description,
|
||||
payment_method_type,
|
||||
} = body;
|
||||
|
||||
if (!amount_cents || amount_cents <= 0) {
|
||||
return new Response(JSON.stringify({ error: "Invalid amount" }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (!association_id) {
|
||||
return new Response(JSON.stringify({ error: "association_id is required" }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Get Stripe mapping for this association
|
||||
const { data: mapping, error: mappingError } = await supabase
|
||||
.from("stripe_account_mappings")
|
||||
.select("*")
|
||||
.eq("association_id", association_id)
|
||||
.eq("is_active", true)
|
||||
.maybeSingle();
|
||||
|
||||
if (mappingError || !mapping) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "No active Stripe configuration found for this association." }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const stripeSecretKey = mapping.stripe_secret_key;
|
||||
if (!stripeSecretKey) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Stripe secret key is not configured for this association." }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
const stripeHeaders: Record<string, string> = {
|
||||
Authorization: `Bearer ${stripeSecretKey}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
};
|
||||
if (mapping.stripe_account_id?.startsWith("acct_")) {
|
||||
stripeHeaders["Stripe-Account"] = mapping.stripe_account_id;
|
||||
}
|
||||
|
||||
// Calculate fees if pass_processing_fee is on
|
||||
let feeCents = 0;
|
||||
const isAch = payment_method_type === "us_bank_account";
|
||||
if (mapping.pass_processing_fee) {
|
||||
if (isAch) {
|
||||
feeCents = Math.min(Math.ceil(amount_cents * 0.008), 500);
|
||||
} else {
|
||||
const feePercent = Number(mapping.processing_fee_percent) || 0.029;
|
||||
const feeFixed = Number(mapping.processing_fee_fixed_cents) || 30;
|
||||
const grossed = Math.ceil((amount_cents + feeFixed) / (1 - feePercent));
|
||||
feeCents = grossed - amount_cents;
|
||||
}
|
||||
}
|
||||
|
||||
const totalCents = amount_cents + feeCents;
|
||||
|
||||
// Create PaymentIntent via Stripe API
|
||||
const stripeParams = new URLSearchParams();
|
||||
stripeParams.append("amount", String(totalCents));
|
||||
stripeParams.append("currency", "usd");
|
||||
stripeParams.append("description", description || "HOA Assessment Payment");
|
||||
stripeParams.append("metadata[association_id]", association_id);
|
||||
stripeParams.append("metadata[user_id]", user.id);
|
||||
if (owner_id) stripeParams.append("metadata[owner_id]", owner_id);
|
||||
if (unit_id) stripeParams.append("metadata[unit_id]", unit_id);
|
||||
stripeParams.append("metadata[net_amount_cents]", String(amount_cents));
|
||||
stripeParams.append("metadata[fee_cents]", String(feeCents));
|
||||
|
||||
if (isAch) {
|
||||
stripeParams.append("payment_method_types[]", "us_bank_account");
|
||||
} else {
|
||||
stripeParams.append("automatic_payment_methods[enabled]", "true");
|
||||
}
|
||||
|
||||
const stripeRes = await fetch("https://api.stripe.com/v1/payment_intents", {
|
||||
method: "POST",
|
||||
headers: stripeHeaders,
|
||||
body: stripeParams.toString(),
|
||||
});
|
||||
|
||||
const stripeData = await stripeRes.json();
|
||||
if (!stripeRes.ok) {
|
||||
console.error("Stripe error:", stripeData);
|
||||
return new Response(
|
||||
JSON.stringify({ error: stripeData.error?.message || "Stripe API error" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Record the payment in our database
|
||||
await supabase.from("stripe_payments").insert({
|
||||
association_id,
|
||||
owner_id: owner_id || null,
|
||||
unit_id: unit_id || null,
|
||||
stripe_payment_intent_id: stripeData.id,
|
||||
amount_cents,
|
||||
fee_cents: feeCents,
|
||||
total_cents: totalCents,
|
||||
payment_method_type: isAch ? "us_bank_account" : "card",
|
||||
status: "pending",
|
||||
description: description || "HOA Assessment Payment",
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
clientSecret: stripeData.client_secret,
|
||||
paymentIntentId: stripeData.id,
|
||||
totalCents,
|
||||
feeCents,
|
||||
}),
|
||||
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error in create-payment-intent:", err);
|
||||
return new Response(
|
||||
JSON.stringify({ error: err.message || "Internal server error" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.3";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
const body = await req.json();
|
||||
const {
|
||||
association_id,
|
||||
amenity_id,
|
||||
pin_id,
|
||||
pin_label,
|
||||
guest_name,
|
||||
guest_email,
|
||||
guest_phone,
|
||||
booking_date,
|
||||
signature_data,
|
||||
amount_cents,
|
||||
success_url,
|
||||
cancel_url,
|
||||
form_data,
|
||||
booking_type,
|
||||
title,
|
||||
start_time,
|
||||
end_time,
|
||||
notes,
|
||||
} = body;
|
||||
|
||||
if (!association_id || !amenity_id || !guest_name || !booking_date) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing required fields" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Save the reservation as pending
|
||||
const { data: booking, error: bookingError } = await supabase
|
||||
.from("amenity_bookings")
|
||||
.insert({
|
||||
amenity_id,
|
||||
association_id,
|
||||
guest_name,
|
||||
guest_email: guest_email || null,
|
||||
guest_phone: guest_phone || null,
|
||||
booking_date,
|
||||
booking_type: booking_type || "rental",
|
||||
title: title || `Reservation: ${pin_label || "Amenity"}`,
|
||||
start_time: start_time || null,
|
||||
end_time: end_time || null,
|
||||
form_data: form_data || {},
|
||||
notes: notes || JSON.stringify({
|
||||
pin_id,
|
||||
pin_label,
|
||||
signature: signature_data || null,
|
||||
form_data: form_data || null,
|
||||
}),
|
||||
status: amount_cents > 0 ? "pending_payment" : "confirmed",
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (bookingError) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: bookingError.message }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Create form inbox entry
|
||||
await supabase.from("form_inbox").insert({
|
||||
source_type: "reservation",
|
||||
source_id: booking.id,
|
||||
association_id,
|
||||
title: `Reservation: ${pin_label || "Amenity Spot"}`,
|
||||
submitter_name: guest_name,
|
||||
submitter_email: guest_email || null,
|
||||
summary: `${pin_label || "Spot"} | Date: ${booking_date} | Fee: ${amount_cents > 0 ? "$" + (amount_cents / 100).toFixed(2) : "Free"}`,
|
||||
status: "new",
|
||||
});
|
||||
|
||||
// Send in-app notifications to admin/manager users
|
||||
try {
|
||||
const { data: adminRoles } = await supabase
|
||||
.from("user_roles")
|
||||
.select("user_id")
|
||||
.in("role", ["admin", "manager"]);
|
||||
|
||||
if (adminRoles && adminRoles.length > 0) {
|
||||
for (const role of adminRoles) {
|
||||
await supabase.rpc("insert_notification", {
|
||||
p_user_id: role.user_id,
|
||||
p_type: "reservation",
|
||||
p_title: `New Reservation: ${pin_label || "Amenity Spot"}`,
|
||||
p_message: `${guest_name} submitted a reservation for ${pin_label || "a spot"} on ${booking_date}.`,
|
||||
p_related_item_id: booking.id,
|
||||
p_related_item_type: "amenity_booking",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (notifErr) {
|
||||
console.error("Failed to send admin notifications:", notifErr);
|
||||
}
|
||||
|
||||
// If no fee — mark pin as rented immediately
|
||||
if (!amount_cents || amount_cents <= 0) {
|
||||
await markPinAsRented(supabase, amenity_id, pin_id);
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, booking_id: booking.id }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Look up Stripe mapping for this association
|
||||
const { data: stripeMapping } = await supabase
|
||||
.from("stripe_account_mappings")
|
||||
.select("*")
|
||||
.eq("association_id", association_id)
|
||||
.eq("is_active", true)
|
||||
.maybeSingle();
|
||||
|
||||
let mapping = stripeMapping;
|
||||
if (!mapping) {
|
||||
const { data: companyMapping } = await supabase
|
||||
.from("stripe_account_mappings")
|
||||
.select("*")
|
||||
.is("association_id", null)
|
||||
.eq("is_active", true)
|
||||
.maybeSingle();
|
||||
mapping = companyMapping;
|
||||
}
|
||||
|
||||
if (!mapping?.stripe_secret_key) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
booking_id: booking.id,
|
||||
payment_note: "No payment gateway configured. Reservation saved as pending.",
|
||||
}),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate fee — always pass processing fees to the payer for reservations
|
||||
let totalCents = Math.round((amount_cents + 30) / (1 - 0.029));
|
||||
|
||||
// Create Stripe Checkout Session
|
||||
const stripeParams = new URLSearchParams();
|
||||
stripeParams.append("mode", "payment");
|
||||
stripeParams.append("line_items[0][price_data][currency]", "usd");
|
||||
stripeParams.append("line_items[0][price_data][unit_amount]", String(totalCents));
|
||||
stripeParams.append("line_items[0][price_data][product_data][name]", `Reservation: ${pin_label || "Amenity"}`);
|
||||
stripeParams.append("line_items[0][quantity]", "1");
|
||||
stripeParams.append("customer_email", guest_email || "");
|
||||
stripeParams.append("success_url", `${success_url}?session_id={CHECKOUT_SESSION_ID}&booking_id=${booking.id}`);
|
||||
stripeParams.append("cancel_url", cancel_url || success_url);
|
||||
stripeParams.append("metadata[booking_id]", booking.id);
|
||||
stripeParams.append("metadata[association_id]", association_id);
|
||||
stripeParams.append("metadata[amenity_id]", amenity_id);
|
||||
stripeParams.append("metadata[pin_id]", pin_id || "");
|
||||
|
||||
const stripeResp = await fetch("https://api.stripe.com/v1/checkout/sessions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${mapping.stripe_secret_key}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: stripeParams.toString(),
|
||||
});
|
||||
|
||||
const session = await stripeResp.json();
|
||||
|
||||
if (!stripeResp.ok) {
|
||||
console.error("Stripe error:", session);
|
||||
return new Response(
|
||||
JSON.stringify({ error: session.error?.message || "Stripe checkout failed" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
booking_id: booking.id,
|
||||
checkout_url: session.url,
|
||||
}),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error in create-reservation-checkout:", err);
|
||||
return new Response(
|
||||
JSON.stringify({ error: err.message || "Internal server error" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Helper: update the pin status to "rented" in the amenity's map_config
|
||||
async function markPinAsRented(supabase: any, amenityId: string, pinId: string) {
|
||||
try {
|
||||
const { data: amenity } = await supabase
|
||||
.from("amenities")
|
||||
.select("map_config")
|
||||
.eq("id", amenityId)
|
||||
.single();
|
||||
|
||||
if (!amenity?.map_config?.pins) return;
|
||||
|
||||
const updatedPins = amenity.map_config.pins.map((pin: any) =>
|
||||
pin.id === pinId ? { ...pin, status: "rented" } : pin
|
||||
);
|
||||
|
||||
await supabase
|
||||
.from("amenities")
|
||||
.update({
|
||||
map_config: { ...amenity.map_config, pins: updatedPins },
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", amenityId);
|
||||
} catch (err) {
|
||||
console.error("Failed to mark pin as rented:", err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||
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, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
class DocuSignConsentRequiredError extends Error {
|
||||
consentUrl: string;
|
||||
|
||||
constructor(consentUrl: string) {
|
||||
super("DocuSign consent required");
|
||||
this.name = "DocuSignConsentRequiredError";
|
||||
this.consentUrl = consentUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function concatBytes(...arrays: Uint8Array[]) {
|
||||
const totalLength = arrays.reduce((sum, array) => sum + array.length, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
|
||||
for (const array of arrays) {
|
||||
result.set(array, offset);
|
||||
offset += array.length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function encodeDerLength(length: number) {
|
||||
if (length < 0x80) {
|
||||
return Uint8Array.of(length);
|
||||
}
|
||||
|
||||
const bytes: number[] = [];
|
||||
let remaining = length;
|
||||
while (remaining > 0) {
|
||||
bytes.unshift(remaining & 0xff);
|
||||
remaining >>= 8;
|
||||
}
|
||||
|
||||
return Uint8Array.of(0x80 | bytes.length, ...bytes);
|
||||
}
|
||||
|
||||
function wrapPkcs1InPkcs8(pkcs1Der: Uint8Array) {
|
||||
const version = Uint8Array.of(0x02, 0x01, 0x00);
|
||||
const rsaEncryptionOid = Uint8Array.of(
|
||||
0x06, 0x09,
|
||||
0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01,
|
||||
);
|
||||
const nullParams = Uint8Array.of(0x05, 0x00);
|
||||
|
||||
const algorithmIdentifier = concatBytes(
|
||||
Uint8Array.of(0x30),
|
||||
encodeDerLength(rsaEncryptionOid.length + nullParams.length),
|
||||
rsaEncryptionOid,
|
||||
nullParams,
|
||||
);
|
||||
|
||||
const privateKey = concatBytes(
|
||||
Uint8Array.of(0x04),
|
||||
encodeDerLength(pkcs1Der.length),
|
||||
pkcs1Der,
|
||||
);
|
||||
|
||||
const body = concatBytes(version, algorithmIdentifier, privateKey);
|
||||
|
||||
return concatBytes(
|
||||
Uint8Array.of(0x30),
|
||||
encodeDerLength(body.length),
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSecretValue(rawValue: string) {
|
||||
return rawValue
|
||||
.trim()
|
||||
.replace(/^['"]/, "")
|
||||
.replace(/['"]$/, "")
|
||||
.replace(/\\r/g, "")
|
||||
.replace(/\\n/g, "\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractPemParts(rawValue: string) {
|
||||
const normalizedValue = normalizeSecretValue(rawValue);
|
||||
const pemMatch = normalizedValue.match(
|
||||
/-----BEGIN\s+([A-Z ]+PRIVATE KEY)-----([\s\S]*?)-----END\s+\1-----/
|
||||
);
|
||||
|
||||
if (pemMatch) {
|
||||
return {
|
||||
pemType: pemMatch[1],
|
||||
pemBody: pemMatch[2].replace(/\s/g, ""),
|
||||
};
|
||||
}
|
||||
|
||||
if (/^[A-Za-z0-9+/=\s]+$/.test(normalizedValue)) {
|
||||
return {
|
||||
pemType: "RAW_BASE64_PRIVATE_KEY",
|
||||
pemBody: normalizedValue.replace(/\s/g, ""),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("DocuSign private key secret must contain a valid PEM or base64 private key");
|
||||
}
|
||||
|
||||
function getDocuSignConsentUrl() {
|
||||
const integrationKey = Deno.env.get("DOCUSIGN_INTEGRATION_KEY");
|
||||
const redirectUri =
|
||||
Deno.env.get("DOCUSIGN_REDIRECT_URI") ||
|
||||
"https://developers.docusign.com/platform/auth/consent";
|
||||
|
||||
if (!integrationKey) {
|
||||
throw new Error("DOCUSIGN_INTEGRATION_KEY not configured");
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: "code",
|
||||
scope: "signature impersonation",
|
||||
client_id: integrationKey,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
return `https://account-d.docusign.com/oauth/auth?${params.toString()}`;
|
||||
}
|
||||
|
||||
// DocuSign JWT Grant: get access token using RSA key
|
||||
async function getDocuSignToken(): Promise<string> {
|
||||
const integrationKey = Deno.env.get("DOCUSIGN_INTEGRATION_KEY");
|
||||
const userId = Deno.env.get("DOCUSIGN_USER_ID");
|
||||
const rsaPrivateKey = Deno.env.get("DOCUSIGN_RSA_PRIVATE_KEY");
|
||||
|
||||
if (!integrationKey || !userId || !rsaPrivateKey) {
|
||||
throw new Error("DocuSign credentials not configured");
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const header = { alg: "RS256", typ: "JWT" };
|
||||
const payload = {
|
||||
iss: integrationKey,
|
||||
sub: userId,
|
||||
aud: "account-d.docusign.com",
|
||||
iat: now,
|
||||
exp: now + 3600,
|
||||
scope: "signature impersonation",
|
||||
};
|
||||
|
||||
const encode = (obj: unknown) =>
|
||||
btoa(JSON.stringify(obj))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
|
||||
const headerB64 = encode(header);
|
||||
const payloadB64 = encode(payload);
|
||||
const signingInput = `${headerB64}.${payloadB64}`;
|
||||
|
||||
const { pemType, pemBody } = extractPemParts(rsaPrivateKey);
|
||||
const isEncryptedPem = pemType === "ENCRYPTED PRIVATE KEY";
|
||||
const isPkcs1Pem = pemType === "RSA PRIVATE KEY";
|
||||
|
||||
if (isEncryptedPem) {
|
||||
throw new Error("DocuSign private key must be an unencrypted PEM key");
|
||||
}
|
||||
|
||||
const { decode: decodeBase64 } = await import("https://deno.land/std@0.168.0/encoding/base64.ts");
|
||||
const rawKeyBytes = decodeBase64(pemBody);
|
||||
const keyBytes = isPkcs1Pem ? wrapPkcs1InPkcs8(rawKeyBytes) : rawKeyBytes;
|
||||
|
||||
let cryptoKey: CryptoKey;
|
||||
try {
|
||||
cryptoKey = await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
keyBytes,
|
||||
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"]
|
||||
);
|
||||
} catch (primaryError) {
|
||||
if (isPkcs1Pem) {
|
||||
throw new Error(`Invalid DocuSign RSA private key: ${primaryError instanceof Error ? primaryError.message : String(primaryError)}`);
|
||||
}
|
||||
|
||||
try {
|
||||
cryptoKey = await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
wrapPkcs1InPkcs8(rawKeyBytes),
|
||||
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"]
|
||||
);
|
||||
} catch {
|
||||
throw new Error(`Invalid DocuSign private key format: ${primaryError instanceof Error ? primaryError.message : String(primaryError)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const signature = await crypto.subtle.sign(
|
||||
"RSASSA-PKCS1-v1_5",
|
||||
cryptoKey,
|
||||
new TextEncoder().encode(signingInput)
|
||||
);
|
||||
|
||||
const signatureB64 = btoa(String.fromCharCode(...new Uint8Array(signature)))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
|
||||
const jwt = `${signingInput}.${signatureB64}`;
|
||||
|
||||
// Exchange JWT for access token
|
||||
const tokenRes = await fetch("https://account-d.docusign.com/oauth/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${jwt}`,
|
||||
});
|
||||
|
||||
if (!tokenRes.ok) {
|
||||
const err = await tokenRes.text();
|
||||
if (err.includes("consent_required")) {
|
||||
throw new DocuSignConsentRequiredError(getDocuSignConsentUrl());
|
||||
}
|
||||
throw new Error(`DocuSign token error: ${err}`);
|
||||
}
|
||||
|
||||
const tokenData = await tokenRes.json();
|
||||
return tokenData.access_token;
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate user auth using getClaims for reliability
|
||||
const authHeader = req.headers.get("authorization");
|
||||
if (!authHeader?.startsWith("Bearer ")) throw new Error("Missing authorization header");
|
||||
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseAnonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
|
||||
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
|
||||
const authClient = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
global: { headers: { Authorization: authHeader } },
|
||||
});
|
||||
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
const { data: claimsData, error: claimsError } = await authClient.auth.getClaims(token);
|
||||
if (claimsError || !claimsData?.claims) throw new Error("Unauthorized");
|
||||
|
||||
const userId = claimsData.claims.sub as string;
|
||||
|
||||
const serviceClient = createClient(supabaseUrl, serviceRoleKey);
|
||||
|
||||
// Check admin/manager role
|
||||
const { data: roles } = await serviceClient
|
||||
.from("user_roles")
|
||||
.select("role")
|
||||
.eq("user_id", userId);
|
||||
|
||||
const hasAccess = roles?.some((r: any) =>
|
||||
["admin", "manager"].includes(r.role)
|
||||
);
|
||||
if (!hasAccess) throw new Error("Insufficient permissions");
|
||||
|
||||
const body = await req.json();
|
||||
const { action } = body;
|
||||
|
||||
if (action === "send") {
|
||||
const {
|
||||
association_id,
|
||||
document_name,
|
||||
document_base64,
|
||||
file_extension,
|
||||
recipients,
|
||||
email_subject,
|
||||
email_body,
|
||||
} = body;
|
||||
|
||||
if (!association_id || !document_name || !document_base64 || !recipients?.length) {
|
||||
throw new Error("Missing required fields: association_id, document_name, document_base64, recipients");
|
||||
}
|
||||
|
||||
const accountId = Deno.env.get("DOCUSIGN_ACCOUNT_ID");
|
||||
if (!accountId) throw new Error("DOCUSIGN_ACCOUNT_ID not configured");
|
||||
|
||||
const accessToken = await getDocuSignToken();
|
||||
|
||||
// Build signers with tabs (signature + date fields)
|
||||
const signers = recipients.map((r: any, idx: number) => ({
|
||||
email: r.email,
|
||||
name: r.name,
|
||||
recipientId: String(idx + 1),
|
||||
routingOrder: String(idx + 1),
|
||||
tabs: {
|
||||
signHereTabs: [
|
||||
{
|
||||
anchorString: "/sig/",
|
||||
anchorXOffset: "0",
|
||||
anchorYOffset: "0",
|
||||
anchorUnits: "pixels",
|
||||
},
|
||||
],
|
||||
dateSignedTabs: [
|
||||
{
|
||||
anchorString: "/date/",
|
||||
anchorXOffset: "0",
|
||||
anchorYOffset: "0",
|
||||
anchorUnits: "pixels",
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
const envelopeDefinition = {
|
||||
emailSubject: email_subject || `Please sign: ${document_name}`,
|
||||
emailBlurb: email_body || "Please review and sign the attached document.",
|
||||
documents: [
|
||||
{
|
||||
documentBase64: document_base64,
|
||||
name: document_name,
|
||||
fileExtension: file_extension || "pdf",
|
||||
documentId: "1",
|
||||
},
|
||||
],
|
||||
recipients: { signers },
|
||||
status: "sent",
|
||||
};
|
||||
|
||||
const envelopeRes = await fetch(
|
||||
`https://demo.docusign.net/restapi/v2.1/accounts/${accountId}/envelopes`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(envelopeDefinition),
|
||||
}
|
||||
);
|
||||
|
||||
if (!envelopeRes.ok) {
|
||||
const errText = await envelopeRes.text();
|
||||
throw new Error(`DocuSign API error [${envelopeRes.status}]: ${errText}`);
|
||||
}
|
||||
|
||||
const envelopeData = await envelopeRes.json();
|
||||
|
||||
// Record in database
|
||||
await serviceClient.from("docusign_envelopes").insert({
|
||||
association_id,
|
||||
envelope_id: envelopeData.envelopeId,
|
||||
document_name,
|
||||
status: "sent",
|
||||
recipients: JSON.stringify(recipients),
|
||||
sent_by: userId,
|
||||
sent_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
envelope_id: envelopeData.envelopeId,
|
||||
status: envelopeData.status,
|
||||
}),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "status") {
|
||||
const { envelope_id } = body;
|
||||
if (!envelope_id) throw new Error("Missing envelope_id");
|
||||
|
||||
const accountId = Deno.env.get("DOCUSIGN_ACCOUNT_ID");
|
||||
if (!accountId) throw new Error("DOCUSIGN_ACCOUNT_ID not configured");
|
||||
|
||||
const accessToken = await getDocuSignToken();
|
||||
|
||||
const statusRes = await fetch(
|
||||
`https://demo.docusign.net/restapi/v2.1/accounts/${accountId}/envelopes/${envelope_id}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
}
|
||||
);
|
||||
|
||||
if (!statusRes.ok) {
|
||||
const errText = await statusRes.text();
|
||||
throw new Error(`DocuSign status error [${statusRes.status}]: ${errText}`);
|
||||
}
|
||||
|
||||
const statusData = await statusRes.json();
|
||||
|
||||
// Update local record
|
||||
await serviceClient
|
||||
.from("docusign_envelopes")
|
||||
.update({
|
||||
status: statusData.status,
|
||||
completed_at: statusData.status === "completed" ? new Date().toISOString() : null,
|
||||
})
|
||||
.eq("envelope_id", envelope_id);
|
||||
|
||||
return new Response(JSON.stringify({ success: true, ...statusData }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Unknown action: ${action}`);
|
||||
} catch (error: unknown) {
|
||||
console.error("DocuSign function error:", error);
|
||||
if (error instanceof DocuSignConsentRequiredError) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: error.message,
|
||||
code: "consent_required",
|
||||
consent_url: error.consentUrl,
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return new Response(JSON.stringify({ success: false, error: message }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
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, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
const STAFF_ROLES = ["admin", "manager"] as const;
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), { status, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
function quote(value: string) {
|
||||
return `"${String(value ?? "").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
async function getAuthorizedClient(req: Request) {
|
||||
const authHeader = req.headers.get("authorization") || "";
|
||||
if (!authHeader.startsWith("Bearer ")) return { error: jsonResponse({ success: false, error: "Unauthorized" }, 401) };
|
||||
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
|
||||
const client = createClient(supabaseUrl, anonKey, { global: { headers: { Authorization: authHeader } } });
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
const { data: claimsData, error } = await client.auth.getClaims(token);
|
||||
const callerId = claimsData?.claims?.sub;
|
||||
if (error || !callerId) return { error: jsonResponse({ success: false, error: "Unauthorized" }, 401) };
|
||||
|
||||
for (const role of STAFF_ROLES) {
|
||||
const { data: hasRole } = await client.rpc("has_role", { _user_id: callerId, _role: role });
|
||||
if (hasRole) return { client, callerId };
|
||||
}
|
||||
return { error: jsonResponse({ success: false, error: "Insufficient permissions" }, 403) };
|
||||
}
|
||||
|
||||
async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, tag: string, timeoutMs = 20000) {
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
text += decoder.decode(value, { stream: true });
|
||||
if (text.includes(`${tag} OK`) || text.includes(`${tag} NO`) || text.includes(`${tag} BAD`)) break;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
async function writeCommand(writer: WritableStreamDefaultWriter<Uint8Array>, tag: string, command: string) {
|
||||
await writer.write(new TextEncoder().encode(`${tag} ${command}\r\n`));
|
||||
}
|
||||
|
||||
function parseHeaderBlock(block: string) {
|
||||
const get = (name: string) => {
|
||||
const match = block.match(new RegExp(`^${name}:\\s*([\\s\\S]*?)(?=\\r?\\n[A-Za-z-]+:|$)`, "im"));
|
||||
return (match?.[1] || "").replace(/\r?\n\s+/g, " ").trim();
|
||||
};
|
||||
const uid = block.match(/UID\s+(\d+)/i)?.[1] || crypto.randomUUID();
|
||||
const flags = block.match(/FLAGS\s+\(([^)]*)\)/i)?.[1] || "";
|
||||
return {
|
||||
id: uid,
|
||||
from: get("From") || "Unknown sender",
|
||||
subject: get("Subject") || "(no subject)",
|
||||
date: get("Date"),
|
||||
unread: !/\\Seen/i.test(flags),
|
||||
};
|
||||
}
|
||||
|
||||
function parseMessages(fetchResponse: string) {
|
||||
return fetchResponse
|
||||
.split(/\r?\n\)\r?\n(?=\* \d+ FETCH|[A-Z]\d+ OK|$)/g)
|
||||
.filter((block) => /FETCH/i.test(block))
|
||||
.map(parseHeaderBlock)
|
||||
.filter((message) => message.from || message.subject)
|
||||
.reverse();
|
||||
}
|
||||
|
||||
function filterMessagesByOpenedAt(messages: ReturnType<typeof parseMessages>, openedSince?: string) {
|
||||
if (!openedSince) return messages;
|
||||
const openedAt = new Date(openedSince).getTime();
|
||||
if (!Number.isFinite(openedAt)) return messages;
|
||||
return messages.filter((message) => {
|
||||
const receivedAt = new Date(message.date).getTime();
|
||||
return Number.isFinite(receivedAt) && receivedAt >= openedAt;
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchImapMessages(config: any, openedSince?: string) {
|
||||
const conn = config.use_tls
|
||||
? await Deno.connectTls({ hostname: config.imap_host, port: Number(config.imap_port || 993) })
|
||||
: await Deno.connect({ hostname: config.imap_host, port: Number(config.imap_port || 143) });
|
||||
|
||||
const reader = conn.readable.getReader();
|
||||
const writer = conn.writable.getWriter();
|
||||
await readUntil(reader, "*");
|
||||
|
||||
await writeCommand(writer, "A1", `LOGIN ${quote(config.imap_username)} ${quote(config.imap_password)}`);
|
||||
const login = await readUntil(reader, "A1");
|
||||
if (!login.includes("A1 OK")) throw new Error("IMAP login failed. Check username, password, host, and port.");
|
||||
|
||||
await writeCommand(writer, "A2", "SELECT INBOX");
|
||||
const selected = await readUntil(reader, "A2");
|
||||
const exists = Number(selected.match(/\*\s+(\d+)\s+EXISTS/i)?.[1] || 0);
|
||||
if (exists === 0) {
|
||||
await writeCommand(writer, "A4", "LOGOUT");
|
||||
await readUntil(reader, "A4", 3000).catch(() => "");
|
||||
conn.close();
|
||||
return [];
|
||||
}
|
||||
|
||||
const start = Math.max(1, exists - 49);
|
||||
await writeCommand(writer, "A3", `FETCH ${start}:${exists} (UID FLAGS BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])`);
|
||||
const fetched = await readUntil(reader, "A3");
|
||||
await writeCommand(writer, "A4", "LOGOUT");
|
||||
await readUntil(reader, "A4", 3000).catch(() => "");
|
||||
conn.close();
|
||||
return filterMessagesByOpenedAt(parseMessages(fetched), openedSince);
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
|
||||
|
||||
try {
|
||||
const auth = await getAuthorizedClient(req);
|
||||
if (auth.error) return auth.error;
|
||||
const { client, callerId } = auth;
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const action = String(body.action || "fetch_messages");
|
||||
|
||||
if (action === "list_configs") {
|
||||
const { data, error } = await client.from("email_inbox_configs").select("id, display_name, email_address, imap_host, imap_port, imap_username, use_tls, is_active, created_at").order("created_at", { ascending: false });
|
||||
if (error) throw error;
|
||||
return jsonResponse({ success: true, configs: data || [] });
|
||||
}
|
||||
|
||||
if (action === "save_config") {
|
||||
const config = body.config || {};
|
||||
if (!config.display_name || !config.email_address || !config.imap_host || !config.imap_username || !config.imap_password) {
|
||||
return jsonResponse({ success: false, error: "Display name, email, IMAP host, username, and password are required." }, 400);
|
||||
}
|
||||
const payload = {
|
||||
user_id: callerId,
|
||||
display_name: String(config.display_name),
|
||||
email_address: String(config.email_address).toLowerCase(),
|
||||
imap_host: String(config.imap_host),
|
||||
imap_port: Number(config.imap_port || 993),
|
||||
imap_username: String(config.imap_username),
|
||||
imap_password: String(config.imap_password),
|
||||
use_tls: config.use_tls !== false,
|
||||
is_active: config.is_active !== false,
|
||||
};
|
||||
const query = config.id
|
||||
? client.from("email_inbox_configs").update(payload).eq("id", config.id).select("id").single()
|
||||
: client.from("email_inbox_configs").insert(payload).select("id").single();
|
||||
const { data, error } = await query;
|
||||
if (error) throw error;
|
||||
return jsonResponse({ success: true, id: data.id });
|
||||
}
|
||||
|
||||
if (action === "delete_config") {
|
||||
if (!body.id) return jsonResponse({ success: false, error: "Inbox config id is required." }, 400);
|
||||
const { error } = await client.from("email_inbox_configs").delete().eq("id", body.id);
|
||||
if (error) throw error;
|
||||
return jsonResponse({ success: true });
|
||||
}
|
||||
|
||||
const configId = body.config_id;
|
||||
const query = client.from("email_inbox_configs").select("*").eq("is_active", true).order("created_at", { ascending: false }).limit(1);
|
||||
const { data: configs, error } = configId ? await client.from("email_inbox_configs").select("*").eq("id", configId).limit(1) : await query;
|
||||
if (error) throw error;
|
||||
const config = configs?.[0];
|
||||
if (!config) return jsonResponse({ success: true, messages: [], configs: [] });
|
||||
const messages = await fetchImapMessages(config, typeof body.opened_since === "string" ? body.opened_since : undefined);
|
||||
return jsonResponse({ success: true, messages, mailbox: { id: config.id, display_name: config.display_name, email_address: config.email_address } });
|
||||
} catch (error) {
|
||||
console.error("[fetch-imap-inbox]", error);
|
||||
return jsonResponse({ success: false, error: error instanceof Error ? error.message : "Failed to fetch inbox" }, 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
function addInterval(dateStr: string, freq: string): string {
|
||||
const d = new Date(dateStr + "T12:00:00");
|
||||
switch (freq) {
|
||||
case "weekly": d.setDate(d.getDate() + 7); break;
|
||||
case "biweekly": d.setDate(d.getDate() + 14); break;
|
||||
case "monthly": d.setMonth(d.getMonth() + 1); break;
|
||||
case "quarterly": d.setMonth(d.getMonth() + 3); break;
|
||||
case "semiannual": d.setMonth(d.getMonth() + 6); break;
|
||||
case "annual": d.setFullYear(d.getFullYear() + 1); break;
|
||||
default: d.setMonth(d.getMonth() + 1);
|
||||
}
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
|
||||
);
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const { data: due, error } = await supabase
|
||||
.from("invoices")
|
||||
.select("*")
|
||||
.not("recurrence_frequency", "is", null)
|
||||
.lte("next_generation_date", today);
|
||||
|
||||
if (error) {
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
let created = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const tmpl of due || []) {
|
||||
try {
|
||||
if (tmpl.recurrence_end_date && tmpl.next_generation_date > tmpl.recurrence_end_date) continue;
|
||||
|
||||
const issueDate = tmpl.next_generation_date || today;
|
||||
const dueOffset = tmpl.due_date && tmpl.issue_date
|
||||
? Math.round((new Date(tmpl.due_date).getTime() - new Date(tmpl.issue_date).getTime()) / 86400000)
|
||||
: 30;
|
||||
const newDue = new Date(issueDate + "T12:00:00");
|
||||
newDue.setDate(newDue.getDate() + dueOffset);
|
||||
|
||||
const { error: insErr } = await supabase.from("invoices").insert({
|
||||
association_id: tmpl.association_id,
|
||||
vendor_name: tmpl.vendor_name,
|
||||
invoice_number: tmpl.invoice_number ? `${tmpl.invoice_number}-${issueDate}` : null,
|
||||
amount: tmpl.amount,
|
||||
status: "pending",
|
||||
issue_date: issueDate,
|
||||
due_date: newDue.toISOString().slice(0, 10),
|
||||
description: tmpl.description,
|
||||
category: tmpl.category,
|
||||
line_items: tmpl.line_items,
|
||||
rate: tmpl.rate,
|
||||
parent_invoice_id: tmpl.id,
|
||||
});
|
||||
if (insErr) { errors.push(insErr.message); continue; }
|
||||
|
||||
const next = addInterval(issueDate, tmpl.recurrence_frequency);
|
||||
const stop = tmpl.recurrence_end_date && next > tmpl.recurrence_end_date;
|
||||
await supabase.from("invoices").update({
|
||||
next_generation_date: stop ? null : next,
|
||||
recurrence_frequency: stop ? null : tmpl.recurrence_frequency,
|
||||
}).eq("id", tmpl.id);
|
||||
|
||||
created++;
|
||||
} catch (e) {
|
||||
errors.push(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ created, errors }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
// Geocode an address with precision-aware fallback chain:
|
||||
// 1. Mapbox Geocoding v6 (rooftop, very precise — primary)
|
||||
// 2. US Census Geocoder (rooftop interpolation, US-only fallback)
|
||||
// 3. Nominatim (OSM) — only accept if result includes a house_number
|
||||
// 4. Photon (Komoot OSM) — only accept if housenumber present
|
||||
// Caches results in public.address_geocodes so subsequent lookups are instant.
|
||||
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
};
|
||||
|
||||
function normalize(addr: string): string {
|
||||
return addr
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/[.,#]/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
interface GeoResult {
|
||||
lat: number;
|
||||
lng: number;
|
||||
display_name: string;
|
||||
source: string;
|
||||
precision: "rooftop" | "interpolated" | "street" | "approximate";
|
||||
}
|
||||
|
||||
// Mapbox Geocoding API v6 — highest precision; address-level results include
|
||||
// match_code data and a precise rooftop point.
|
||||
async function viaMapbox(
|
||||
address: string,
|
||||
options?: { context?: string | null },
|
||||
): Promise<GeoResult | null> {
|
||||
const token =
|
||||
Deno.env.get("MAPBOX_SECRET_TOKEN") || Deno.env.get("MAPBOX_PUBLIC_TOKEN");
|
||||
if (!token) return null;
|
||||
const query = options?.context?.trim()
|
||||
? `${address}, ${options.context.trim()}`
|
||||
: address;
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
limit: "1",
|
||||
autocomplete: "false",
|
||||
types: "address,street",
|
||||
access_token: token,
|
||||
});
|
||||
const url = `https://api.mapbox.com/search/geocode/v6/forward?${params.toString()}`;
|
||||
try {
|
||||
const r = await fetch(url, { headers: { "User-Agent": "lovable-geocode/1.0" } });
|
||||
if (!r.ok) return null;
|
||||
const data = await r.json();
|
||||
const feat = data?.features?.[0];
|
||||
if (!feat) return null;
|
||||
const [lng, lat] = feat.geometry?.coordinates || [];
|
||||
if (typeof lat !== "number" || typeof lng !== "number") return null;
|
||||
const props = feat.properties || {};
|
||||
const featureType: string = props.feature_type || "";
|
||||
// address = rooftop, street = street centerline, place/locality = city, etc.
|
||||
let precision: GeoResult["precision"] = "approximate";
|
||||
if (featureType === "address") precision = "rooftop";
|
||||
else if (featureType === "street") precision = "street";
|
||||
else if (featureType === "block" || featureType === "secondary_address")
|
||||
precision = "interpolated";
|
||||
return {
|
||||
lat,
|
||||
lng,
|
||||
display_name: props.full_address || props.name || address,
|
||||
source: "mapbox",
|
||||
precision,
|
||||
};
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Census returns interpolated rooftop coordinates from TIGER address ranges.
|
||||
async function viaCensus(address: string): Promise<GeoResult | null> {
|
||||
const url = `https://geocoding.geo.census.gov/geocoder/locations/onelineaddress?address=${encodeURIComponent(
|
||||
address,
|
||||
)}&benchmark=Public_AR_Current&format=json`;
|
||||
try {
|
||||
const r = await fetch(url, { headers: { "User-Agent": "lovable-geocode/1.0" } });
|
||||
if (!r.ok) return null;
|
||||
const data = await r.json();
|
||||
const match = data?.result?.addressMatches?.[0];
|
||||
if (!match?.coordinates) return null;
|
||||
return {
|
||||
lat: match.coordinates.y,
|
||||
lng: match.coordinates.x,
|
||||
display_name: match.matchedAddress || address,
|
||||
source: "census",
|
||||
precision: "interpolated",
|
||||
};
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function viaNominatim(address: string): Promise<GeoResult | null> {
|
||||
const url = `https://nominatim.openstreetmap.org/search?format=json&addressdetails=1&limit=5&q=${encodeURIComponent(
|
||||
address,
|
||||
)}`;
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": "lovable-geocode/1.0 (contact: support@avria.cloud)",
|
||||
"Accept-Language": "en",
|
||||
},
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
const data = await r.json();
|
||||
if (!Array.isArray(data) || !data.length) return null;
|
||||
const withHouse = data.find(
|
||||
(d: any) => d?.address?.house_number || d?.class === "building",
|
||||
);
|
||||
const pick = withHouse || data[0];
|
||||
const precision: GeoResult["precision"] = withHouse
|
||||
? "rooftop"
|
||||
: pick?.type === "residential" || pick?.class === "highway"
|
||||
? "street"
|
||||
: "approximate";
|
||||
return {
|
||||
lat: parseFloat(pick.lat),
|
||||
lng: parseFloat(pick.lon),
|
||||
display_name: pick.display_name,
|
||||
source: "nominatim",
|
||||
precision,
|
||||
};
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function viaPhoton(address: string): Promise<GeoResult | null> {
|
||||
const url = `https://photon.komoot.io/api/?limit=5&q=${encodeURIComponent(address)}`;
|
||||
try {
|
||||
const r = await fetch(url, { headers: { "User-Agent": "lovable-geocode/1.0" } });
|
||||
if (!r.ok) return null;
|
||||
const data = await r.json();
|
||||
const features: any[] = data?.features || [];
|
||||
if (!features.length) return null;
|
||||
const withHouse = features.find((f) => f?.properties?.housenumber);
|
||||
const pick = withHouse || features[0];
|
||||
const [lng, lat] = pick.geometry?.coordinates || [];
|
||||
if (typeof lat !== "number" || typeof lng !== "number") return null;
|
||||
const p = pick.properties || {};
|
||||
const display = [
|
||||
p.housenumber && p.street ? `${p.housenumber} ${p.street}` : p.street || p.name,
|
||||
p.city || p.town || p.village,
|
||||
p.state,
|
||||
p.postcode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
const precision: GeoResult["precision"] = withHouse
|
||||
? "rooftop"
|
||||
: p.osm_key === "highway"
|
||||
? "street"
|
||||
: "approximate";
|
||||
return {
|
||||
lat,
|
||||
lng,
|
||||
display_name: display || address,
|
||||
source: "photon",
|
||||
precision,
|
||||
};
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
|
||||
|
||||
try {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const address: string | undefined = body?.address;
|
||||
const context = typeof body?.context === "string" ? body.context.trim() : "";
|
||||
const forceRefresh: boolean = body?.refresh === true;
|
||||
if (!address || typeof address !== "string" || address.trim().length < 3) {
|
||||
return new Response(JSON.stringify({ error: "Missing address" }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
const cleaned = address.trim();
|
||||
const effectiveQuery = context ? `${cleaned}, ${context}` : cleaned;
|
||||
const key = normalize(effectiveQuery);
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
|
||||
);
|
||||
|
||||
if (!forceRefresh) {
|
||||
const { data: cached } = await supabase
|
||||
.from("address_geocodes")
|
||||
.select("lat,lng,display_name,source,not_found")
|
||||
.eq("address_key", key)
|
||||
.maybeSingle();
|
||||
|
||||
if (cached) {
|
||||
// Skip cache entries from older non-Mapbox sources so they re-resolve
|
||||
// through the more precise Mapbox provider.
|
||||
const sourceStr = String(cached.source || "");
|
||||
const isMapboxCache = sourceStr.startsWith("mapbox");
|
||||
if (isMapboxCache || cached.not_found) {
|
||||
if (cached.not_found || cached.lat == null) {
|
||||
return new Response(JSON.stringify({ result: null, cached: true }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
result: {
|
||||
lat: cached.lat,
|
||||
lng: cached.lng,
|
||||
display_name: cached.display_name,
|
||||
source: cached.source,
|
||||
},
|
||||
cached: true,
|
||||
}),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Provider chain — Mapbox first (most precise), then free fallbacks.
|
||||
const mapbox = await viaMapbox(cleaned, { context });
|
||||
let result: GeoResult | null = mapbox;
|
||||
|
||||
if (!result || result.precision === "approximate") {
|
||||
const census = await viaCensus(effectiveQuery);
|
||||
if (census && (!result || census.precision === "interpolated")) result = census;
|
||||
}
|
||||
if (!result || result.precision === "approximate") {
|
||||
const nom = await viaNominatim(effectiveQuery);
|
||||
if (nom && (nom.precision === "rooftop" || !result)) result = nom;
|
||||
}
|
||||
if (!result || (result.precision !== "rooftop" && result.precision !== "interpolated")) {
|
||||
const photon = await viaPhoton(effectiveQuery);
|
||||
if (photon && (photon.precision === "rooftop" || !result)) result = photon;
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from("address_geocodes")
|
||||
.upsert(
|
||||
{
|
||||
address_key: key,
|
||||
address: cleaned,
|
||||
lat: result?.lat ?? null,
|
||||
lng: result?.lng ?? null,
|
||||
display_name: result?.display_name ?? null,
|
||||
source: result?.source
|
||||
? `${result.source}:${result.precision}`
|
||||
: null,
|
||||
not_found: !result,
|
||||
},
|
||||
{ onConflict: "address_key" },
|
||||
);
|
||||
|
||||
return new Response(JSON.stringify({ result, cached: false }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : "Unknown error";
|
||||
return new Response(JSON.stringify({ error: msg }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
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",
|
||||
};
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
if (!["GET", "POST"].includes(req.method)) {
|
||||
return new Response(JSON.stringify({ error: "Method not allowed" }), {
|
||||
status: 405,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const proofId = req.method === "GET"
|
||||
? new URL(req.url).searchParams.get("proofId")
|
||||
: (await req.json()).proofId;
|
||||
|
||||
if (!proofId) {
|
||||
return new Response(JSON.stringify({ error: "proofId is required" }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, serviceKey);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("document_validation_proofs")
|
||||
.select("id, document_title, document_type, generated_at, verification_hash, associations(name)")
|
||||
.eq("id", proofId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return new Response(JSON.stringify({ error: "Proof not found" }), {
|
||||
status: 404,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "public, max-age=60",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[get-document-validation-proof]", error);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : "Unexpected error",
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
};
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
const apiKey = Deno.env.get('GOOGLE_MAPS_API_KEY');
|
||||
if (!apiKey) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Google Maps API key not configured' }),
|
||||
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ key: apiKey }),
|
||||
{ headers: { ...corsHeaders, 'Content-Type': 'application/json', 'Cache-Control': 'private, max-age=3600' } }
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
// Returns the Mapbox public token for client-side map rendering.
|
||||
// Public tokens (pk.*) are safe to expose; we fetch via edge function so
|
||||
// the token can be rotated without redeploying the frontend.
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
};
|
||||
|
||||
Deno.serve((req) => {
|
||||
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
|
||||
const token = Deno.env.get("MAPBOX_PUBLIC_TOKEN") || "";
|
||||
return new Response(JSON.stringify({ token }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const GOOGLE_CLIENT_ID = Deno.env.get("GOOGLE_CLIENT_ID");
|
||||
const GOOGLE_CLIENT_SECRET = Deno.env.get("GOOGLE_CLIENT_SECRET");
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
||||
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
|
||||
if (!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) {
|
||||
return new Response(JSON.stringify({ error: "Google OAuth credentials not configured" }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { action, code, redirect_uri } = body;
|
||||
const serviceClient = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);
|
||||
|
||||
// Authenticate user
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
// For check_status, return not-connected instead of 401
|
||||
if (action === "check_status") {
|
||||
return new Response(JSON.stringify({ connected: false }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
let userId: string | null = null;
|
||||
|
||||
try {
|
||||
const payload = token.split(".")[1];
|
||||
if (!payload) throw new Error("Missing JWT payload");
|
||||
const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
||||
const decoded = JSON.parse(atob(padded));
|
||||
userId = typeof decoded?.sub === "string" ? decoded.sub : null;
|
||||
} catch (error) {
|
||||
console.error("google-drive-auth token decode failed", error);
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
if (action === "check_status") {
|
||||
return new Response(JSON.stringify({ connected: false }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Check staff role (Google Drive is staff-only)
|
||||
const { data: roles } = await serviceClient
|
||||
.from("user_roles")
|
||||
.select("role")
|
||||
.eq("user_id", userId);
|
||||
|
||||
const STAFF_ROLES = ["admin", "manager", "employee", "staff"];
|
||||
const isStaff = roles?.some((r: any) => STAFF_ROLES.includes(r.role));
|
||||
if (!isStaff) {
|
||||
if (action === "check_status") {
|
||||
return new Response(JSON.stringify({ connected: false }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ error: "Only staff can connect Google Drive" }), {
|
||||
status: 403,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "get_auth_url") {
|
||||
const scopes = [
|
||||
"https://www.googleapis.com/auth/drive",
|
||||
"https://www.googleapis.com/auth/calendar.readonly",
|
||||
].join(" ");
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: GOOGLE_CLIENT_ID,
|
||||
redirect_uri: redirect_uri,
|
||||
response_type: "code",
|
||||
scope: scopes,
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
state: "drive_connect",
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ url: `https://accounts.google.com/o/oauth2/v2/auth?${params}` }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "exchange_code") {
|
||||
if (!code || !redirect_uri) {
|
||||
return new Response(JSON.stringify({ error: "Missing code or redirect_uri" }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: GOOGLE_CLIENT_ID,
|
||||
client_secret: GOOGLE_CLIENT_SECRET,
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri,
|
||||
}),
|
||||
});
|
||||
|
||||
const tokenData = await tokenRes.json();
|
||||
if (tokenData.error) {
|
||||
return new Response(JSON.stringify({ error: tokenData.error_description || tokenData.error }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + tokenData.expires_in * 1000).toISOString();
|
||||
|
||||
const { error: upsertError } = await serviceClient
|
||||
.from("google_drive_tokens")
|
||||
.upsert({
|
||||
user_id: userId,
|
||||
access_token: tokenData.access_token,
|
||||
refresh_token: tokenData.refresh_token,
|
||||
token_expires_at: expiresAt,
|
||||
}, { onConflict: "user_id" });
|
||||
|
||||
if (upsertError) {
|
||||
return new Response(JSON.stringify({ error: "Failed to store tokens" }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "check_status") {
|
||||
const { data: tokenRow } = await serviceClient
|
||||
.from("google_drive_tokens")
|
||||
.select("id, token_expires_at")
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
|
||||
return new Response(JSON.stringify({ connected: !!tokenRow }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "disconnect") {
|
||||
await serviceClient
|
||||
.from("google_drive_tokens")
|
||||
.delete()
|
||||
.eq("user_id", userId);
|
||||
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ error: "Unknown action" }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("google-drive-auth error:", err);
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function extractUserIdFromAuthHeader(authHeader: string | null) {
|
||||
if (!authHeader?.startsWith("Bearer ")) return null;
|
||||
|
||||
try {
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
const payload = token.split(".")[1];
|
||||
if (!payload) return null;
|
||||
|
||||
const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
||||
const decoded = JSON.parse(atob(padded));
|
||||
|
||||
return typeof decoded?.sub === "string" ? decoded.sub : null;
|
||||
} catch (error) {
|
||||
console.error("google-drive-proxy token decode failed", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mapGoogleDriveError(error: any) {
|
||||
const reason = error?.details?.find?.((detail: any) => detail?.reason)?.reason
|
||||
?? error?.errors?.[0]?.reason;
|
||||
const message = error?.message || "Google Drive request failed.";
|
||||
|
||||
if (/oauth client was not found|invalid_client/i.test(message) || reason === "invalid_client") {
|
||||
return {
|
||||
error: "Google Drive OAuth credentials are invalid or missing. Update the Google client ID/secret in the backend, then reconnect Google Drive.",
|
||||
code: "OAUTH_CLIENT_NOT_FOUND",
|
||||
reconnectRequired: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (reason === "ACCESS_TOKEN_SCOPE_INSUFFICIENT" || reason === "insufficientPermissions") {
|
||||
return {
|
||||
error: "Your Google connection needs to be refreshed for Drive access. Disconnect Google and connect it again.",
|
||||
code: "RECONNECT_REQUIRED",
|
||||
reconnectRequired: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (reason === "SERVICE_DISABLED" || /google drive api has not been used|drive\.googleapis\.com|api.*disabled/i.test(message)) {
|
||||
return {
|
||||
error: "Google Drive API is not enabled in the connected Google project.",
|
||||
code: "API_DISABLED",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
error: message,
|
||||
code: "GOOGLE_API_ERROR",
|
||||
};
|
||||
}
|
||||
|
||||
async function getValidAccessToken(
|
||||
serviceClient: any,
|
||||
userId: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
): Promise<string> {
|
||||
const { data: tokenRow, error } = await serviceClient
|
||||
.from("google_drive_tokens")
|
||||
.select("*")
|
||||
.eq("user_id", userId)
|
||||
.single();
|
||||
|
||||
if (error || !tokenRow) throw new Error("NOT_CONNECTED");
|
||||
|
||||
const expiresAt = new Date(tokenRow.token_expires_at).getTime();
|
||||
if (Date.now() < expiresAt - 60000) {
|
||||
return tokenRow.access_token;
|
||||
}
|
||||
|
||||
const refreshRes = await fetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
refresh_token: tokenRow.refresh_token,
|
||||
grant_type: "refresh_token",
|
||||
}),
|
||||
});
|
||||
|
||||
const refreshData = await refreshRes.json();
|
||||
if (refreshData.error) throw new Error(refreshData.error_description || refreshData.error || "REFRESH_FAILED");
|
||||
|
||||
const newExpiresAt = new Date(Date.now() + refreshData.expires_in * 1000).toISOString();
|
||||
|
||||
await serviceClient
|
||||
.from("google_drive_tokens")
|
||||
.update({
|
||||
access_token: refreshData.access_token,
|
||||
token_expires_at: newExpiresAt,
|
||||
})
|
||||
.eq("user_id", userId);
|
||||
|
||||
return refreshData.access_token;
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const GOOGLE_CLIENT_ID = Deno.env.get("GOOGLE_CLIENT_ID")!;
|
||||
const GOOGLE_CLIENT_SECRET = Deno.env.get("GOOGLE_CLIENT_SECRET")!;
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
||||
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
|
||||
if (!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) {
|
||||
return jsonResponse({
|
||||
error: "Google Drive OAuth credentials are not configured in the backend.",
|
||||
code: "OAUTH_CREDENTIALS_MISSING",
|
||||
reconnectRequired: true,
|
||||
});
|
||||
}
|
||||
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
const userId = extractUserIdFromAuthHeader(authHeader);
|
||||
if (!userId) {
|
||||
return jsonResponse({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const serviceClient = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);
|
||||
|
||||
const { data: roles } = await serviceClient
|
||||
.from("user_roles")
|
||||
.select("role")
|
||||
.eq("user_id", userId);
|
||||
const isAdmin = roles?.some((role: any) => role.role === "admin");
|
||||
|
||||
const body = await req.json();
|
||||
const { action, folder_id, file_id } = body;
|
||||
|
||||
if (action === "list_shared_drives") {
|
||||
if (!isAdmin) {
|
||||
return jsonResponse({ error: "Only admins can browse Drive" }, 403);
|
||||
}
|
||||
|
||||
const accessToken = await getValidAccessToken(serviceClient, userId, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET);
|
||||
|
||||
const drivesRes = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/drives?pageSize=100`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
||||
);
|
||||
|
||||
const drivesData = await drivesRes.json();
|
||||
if (drivesData.error) {
|
||||
return jsonResponse(mapGoogleDriveError(drivesData.error));
|
||||
}
|
||||
|
||||
return jsonResponse(drivesData);
|
||||
}
|
||||
|
||||
if (action === "list_files") {
|
||||
if (!isAdmin) {
|
||||
return jsonResponse({ error: "Only admins can browse Drive" }, 403);
|
||||
}
|
||||
|
||||
const accessToken = await getValidAccessToken(serviceClient, userId, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET);
|
||||
const parentId = folder_id || "root";
|
||||
const query = `'${parentId}' in parents and trashed = false`;
|
||||
const fields = "files(id,name,mimeType,iconLink,webViewLink,size,modifiedTime),nextPageToken";
|
||||
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
fields,
|
||||
orderBy: "folder,name",
|
||||
pageSize: "100",
|
||||
supportsAllDrives: "true",
|
||||
includeItemsFromAllDrives: "true",
|
||||
corpora: "allDrives",
|
||||
});
|
||||
|
||||
const driveRes = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files?${params}`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
||||
);
|
||||
|
||||
const driveData = await driveRes.json();
|
||||
if (driveData.error) {
|
||||
return jsonResponse(mapGoogleDriveError(driveData.error));
|
||||
}
|
||||
|
||||
return jsonResponse(driveData);
|
||||
}
|
||||
|
||||
if (action === "download_file") {
|
||||
if (!file_id) {
|
||||
return jsonResponse({ error: "Missing file_id" }, 400);
|
||||
}
|
||||
|
||||
let tokenUserId = userId;
|
||||
if (!isAdmin) {
|
||||
const { data: sharedFile } = await serviceClient
|
||||
.from("shared_drive_files")
|
||||
.select("shared_by")
|
||||
.eq("drive_file_id", file_id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!sharedFile?.shared_by) {
|
||||
return jsonResponse({ error: "File not found or not shared" }, 404);
|
||||
}
|
||||
tokenUserId = sharedFile.shared_by;
|
||||
}
|
||||
|
||||
const accessToken = await getValidAccessToken(serviceClient, tokenUserId, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET);
|
||||
|
||||
const metaRes = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files/${file_id}?fields=name,mimeType&supportsAllDrives=true`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
||||
);
|
||||
const meta = await metaRes.json();
|
||||
if (meta?.error) {
|
||||
return jsonResponse(mapGoogleDriveError(meta.error));
|
||||
}
|
||||
|
||||
const downloadRes = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files/${file_id}?alt=media&supportsAllDrives=true`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
||||
);
|
||||
|
||||
if (!downloadRes.ok) {
|
||||
const errText = await downloadRes.text();
|
||||
try {
|
||||
const parsed = JSON.parse(errText);
|
||||
if (parsed?.error) {
|
||||
return jsonResponse(mapGoogleDriveError(parsed.error));
|
||||
}
|
||||
} catch {
|
||||
// fall through to generic error response
|
||||
}
|
||||
|
||||
return jsonResponse({ error: `Download failed: ${errText}` });
|
||||
}
|
||||
|
||||
const fileBlob = await downloadRes.blob();
|
||||
return new Response(fileBlob, {
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": meta.mimeType || "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${meta.name}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "upload_file") {
|
||||
if (!isAdmin) {
|
||||
return jsonResponse({ error: "Only admins can upload to Drive" }, 403);
|
||||
}
|
||||
|
||||
const { file_name, file_base64, mime_type, parent_folder_id } = body;
|
||||
if (!file_name || !file_base64) {
|
||||
return jsonResponse({ error: "Missing file_name or file_base64" }, 400);
|
||||
}
|
||||
|
||||
const accessToken = await getValidAccessToken(serviceClient, userId, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET);
|
||||
|
||||
// Convert base64 to binary
|
||||
const binaryStr = atob(file_base64);
|
||||
const bytes = new Uint8Array(binaryStr.length);
|
||||
for (let i = 0; i < binaryStr.length; i++) {
|
||||
bytes[i] = binaryStr.charCodeAt(i);
|
||||
}
|
||||
|
||||
// Create file metadata
|
||||
const metadata: any = { name: file_name };
|
||||
if (parent_folder_id) {
|
||||
metadata.parents = [parent_folder_id];
|
||||
}
|
||||
|
||||
// Multipart upload
|
||||
const boundary = "----LovableBoundary" + Date.now();
|
||||
const metaPart = JSON.stringify(metadata);
|
||||
const contentType = mime_type || "application/octet-stream";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const beforeFile = encoder.encode(
|
||||
`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${metaPart}\r\n--${boundary}\r\nContent-Type: ${contentType}\r\n\r\n`
|
||||
);
|
||||
const afterFile = encoder.encode(`\r\n--${boundary}--`);
|
||||
|
||||
const uploadBody = new Uint8Array(beforeFile.length + bytes.length + afterFile.length);
|
||||
uploadBody.set(beforeFile, 0);
|
||||
uploadBody.set(bytes, beforeFile.length);
|
||||
uploadBody.set(afterFile, beforeFile.length + bytes.length);
|
||||
|
||||
const uploadRes = await fetch(
|
||||
"https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&supportsAllDrives=true",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": `multipart/related; boundary=${boundary}`,
|
||||
},
|
||||
body: uploadBody,
|
||||
}
|
||||
);
|
||||
|
||||
const uploadData = await uploadRes.json();
|
||||
if (uploadData.error) {
|
||||
return jsonResponse(mapGoogleDriveError(uploadData.error));
|
||||
}
|
||||
|
||||
return jsonResponse({ success: true, file: uploadData });
|
||||
}
|
||||
|
||||
if (action === "create_folder") {
|
||||
if (!isAdmin) {
|
||||
return jsonResponse({ error: "Only admins can create Drive folders" }, 403);
|
||||
}
|
||||
|
||||
const { folder_name, parent_folder_id: parentId } = body;
|
||||
if (!folder_name) {
|
||||
return jsonResponse({ error: "Missing folder_name" }, 400);
|
||||
}
|
||||
|
||||
const accessToken = await getValidAccessToken(serviceClient, userId, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET);
|
||||
|
||||
const metadata: any = {
|
||||
name: folder_name,
|
||||
mimeType: "application/vnd.google-apps.folder",
|
||||
};
|
||||
if (parentId) metadata.parents = [parentId];
|
||||
|
||||
const createRes = await fetch(
|
||||
"https://www.googleapis.com/drive/v3/files?supportsAllDrives=true",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(metadata),
|
||||
}
|
||||
);
|
||||
|
||||
const createData = await createRes.json();
|
||||
if (createData.error) {
|
||||
return jsonResponse(mapGoogleDriveError(createData.error));
|
||||
}
|
||||
|
||||
return jsonResponse({ success: true, folder: createData });
|
||||
}
|
||||
|
||||
return jsonResponse({ error: "Unknown action" }, 400);
|
||||
} catch (err: any) {
|
||||
console.error("google-drive-proxy error:", err);
|
||||
|
||||
if (err?.message === "NOT_CONNECTED") {
|
||||
return jsonResponse({
|
||||
error: "Google account not connected. Connect Google first.",
|
||||
code: "NOT_CONNECTED",
|
||||
});
|
||||
}
|
||||
|
||||
if (/invalid_grant|refresh token|REFRESH_FAILED/i.test(err?.message || "")) {
|
||||
return jsonResponse({
|
||||
error: "Your Google connection expired. Disconnect Google and connect it again.",
|
||||
code: "RECONNECT_REQUIRED",
|
||||
reconnectRequired: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (/oauth client was not found|invalid_client/i.test(err?.message || "")) {
|
||||
return jsonResponse({
|
||||
error: "Google Drive OAuth credentials are invalid or missing. Update the Google client ID/secret in the backend, then reconnect Google Drive.",
|
||||
code: "OAUTH_CLIENT_NOT_FOUND",
|
||||
reconnectRequired: true,
|
||||
});
|
||||
}
|
||||
|
||||
return jsonResponse({ error: err.message }, 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
};
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const { address, type, width, height } = await req.json();
|
||||
|
||||
if (!address) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: 'Address is required' }),
|
||||
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const apiKey = Deno.env.get('GOOGLE_MAPS_API_KEY');
|
||||
if (!apiKey) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: 'Google Maps API key not configured' }),
|
||||
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const w = width || 320;
|
||||
const h = height || 240;
|
||||
const encodedAddress = encodeURIComponent(address);
|
||||
|
||||
if (type === 'satellite') {
|
||||
// Return a static satellite map URL
|
||||
const url = `https://maps.googleapis.com/maps/api/staticmap?center=${encodedAddress}&zoom=18&size=${w}x${h}&maptype=satellite&key=${apiKey}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: `Static map request failed: ${response.status}` }),
|
||||
{ status: response.status, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const imageBuffer = await response.arrayBuffer();
|
||||
return new Response(imageBuffer, {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' },
|
||||
});
|
||||
}
|
||||
|
||||
// Default: Street View
|
||||
// First check if Street View is available via metadata
|
||||
const metaUrl = `https://maps.googleapis.com/maps/api/streetview/metadata?location=${encodedAddress}&key=${apiKey}`;
|
||||
const metaRes = await fetch(metaUrl);
|
||||
const metaData = await metaRes.json();
|
||||
|
||||
if (metaData.status !== 'OK') {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, available: false, error: 'Street View not available' }),
|
||||
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// Street View is available, fetch the image
|
||||
const svUrl = `https://maps.googleapis.com/maps/api/streetview?size=${w}x${h}&location=${encodedAddress}&fov=90&heading=0&pitch=5&key=${apiKey}`;
|
||||
const svRes = await fetch(svUrl);
|
||||
|
||||
if (!svRes.ok) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: `Street View request failed: ${svRes.status}` }),
|
||||
{ status: svRes.status, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const svBuffer = await svRes.arrayBuffer();
|
||||
return new Response(svBuffer, {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'image/jpeg', 'Cache-Control': 'public, max-age=86400' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Google Maps proxy error:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }),
|
||||
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { createClient } from 'npm:@supabase/supabase-js@2'
|
||||
import { WebhookError, verifyWebhookRequest } from 'npm:@lovable.dev/webhooks-js'
|
||||
|
||||
// Suppression event payload sent by the Go API when Mailgun reports
|
||||
// a bounce, complaint, or unsubscribe.
|
||||
interface SuppressionPayload {
|
||||
email: string
|
||||
reason: 'bounce' | 'complaint' | 'unsubscribe'
|
||||
message_id?: string
|
||||
metadata?: Record<string, unknown>
|
||||
is_retry: boolean
|
||||
retry_count: number
|
||||
}
|
||||
|
||||
function parseSuppressionPayload(body: string): SuppressionPayload {
|
||||
const parsed = JSON.parse(body)
|
||||
if (!parsed.data) {
|
||||
throw new Error('Missing data field in payload')
|
||||
}
|
||||
const data = parsed.data as SuppressionPayload
|
||||
if (!data.email || !data.reason) {
|
||||
throw new Error('Missing required fields: email, reason')
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
function jsonResponse(data: Record<string, unknown>, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method !== 'POST') {
|
||||
return jsonResponse({ error: 'Method not allowed' }, 405)
|
||||
}
|
||||
|
||||
const apiKey = Deno.env.get('LOVABLE_API_KEY')
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')
|
||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')
|
||||
|
||||
if (!apiKey || !supabaseUrl || !supabaseServiceKey) {
|
||||
console.error('Missing required environment variables')
|
||||
return jsonResponse({ error: 'Server configuration error' }, 500)
|
||||
}
|
||||
|
||||
// Verify HMAC signature using the Lovable API Key (same as auth-email-hook)
|
||||
let payload: SuppressionPayload
|
||||
try {
|
||||
const verified = await verifyWebhookRequest({
|
||||
req,
|
||||
secret: apiKey,
|
||||
parser: parseSuppressionPayload,
|
||||
})
|
||||
payload = verified.payload
|
||||
} catch (error) {
|
||||
if (error instanceof WebhookError) {
|
||||
switch (error.code) {
|
||||
case 'invalid_signature':
|
||||
console.error('Invalid webhook signature')
|
||||
return jsonResponse({ error: 'Invalid signature' }, 401)
|
||||
case 'stale_timestamp':
|
||||
console.error('Stale webhook timestamp')
|
||||
return jsonResponse({ error: 'Stale timestamp' }, 401)
|
||||
case 'invalid_payload':
|
||||
case 'invalid_json':
|
||||
console.error('Invalid payload', { code: error.code })
|
||||
return jsonResponse({ error: 'Invalid payload' }, 400)
|
||||
default:
|
||||
console.error('Webhook verification failed', {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
})
|
||||
return jsonResponse({ error: 'Verification failed' }, 401)
|
||||
}
|
||||
}
|
||||
console.error('Unexpected error during verification', { error })
|
||||
return jsonResponse({ error: 'Internal error' }, 500)
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
const normalizedEmail = payload.email.toLowerCase()
|
||||
|
||||
// 1. Upsert to suppressed_emails (idempotent — safe for retries)
|
||||
const { error: suppressError } = await supabase
|
||||
.from('suppressed_emails')
|
||||
.upsert(
|
||||
{
|
||||
email: normalizedEmail,
|
||||
reason: payload.reason,
|
||||
metadata: payload.metadata ?? null,
|
||||
},
|
||||
{ onConflict: 'email' },
|
||||
)
|
||||
|
||||
if (suppressError) {
|
||||
console.error('Failed to upsert suppressed email', {
|
||||
error: suppressError,
|
||||
email_redacted: normalizedEmail[0] + '***@' + normalizedEmail.split('@')[1],
|
||||
})
|
||||
return jsonResponse({ error: 'Failed to write suppression' }, 500)
|
||||
}
|
||||
|
||||
// 2. Append a new log entry for the suppression event (never update existing rows)
|
||||
const sendLogStatus = mapReasonToStatus(payload.reason)
|
||||
const sendLogMessage = mapReasonToMessage(payload.reason)
|
||||
|
||||
const { error: insertError } = await supabase
|
||||
.from('email_send_log')
|
||||
.insert({
|
||||
message_id: payload.message_id ?? null,
|
||||
template_name: 'system',
|
||||
recipient_email: normalizedEmail,
|
||||
status: sendLogStatus,
|
||||
error_message: sendLogMessage,
|
||||
metadata: payload.metadata ?? null,
|
||||
})
|
||||
|
||||
if (insertError) {
|
||||
// Non-fatal — log and continue. The suppression was already recorded.
|
||||
console.warn('Failed to insert email_send_log', {
|
||||
error: insertError,
|
||||
})
|
||||
}
|
||||
|
||||
console.log('Suppression processed', {
|
||||
email_redacted: normalizedEmail[0] + '***@' + normalizedEmail.split('@')[1],
|
||||
reason: payload.reason,
|
||||
is_retry: payload.is_retry,
|
||||
retry_count: payload.retry_count,
|
||||
has_message_id: !!payload.message_id,
|
||||
})
|
||||
|
||||
return jsonResponse({ success: true })
|
||||
})
|
||||
|
||||
function mapReasonToStatus(
|
||||
reason: string,
|
||||
): 'bounced' | 'complained' | 'suppressed' {
|
||||
switch (reason) {
|
||||
case 'bounce':
|
||||
return 'bounced'
|
||||
case 'complaint':
|
||||
return 'complained'
|
||||
default:
|
||||
return 'suppressed'
|
||||
}
|
||||
}
|
||||
|
||||
function mapReasonToMessage(reason: string): string {
|
||||
switch (reason) {
|
||||
case 'bounce':
|
||||
return 'Permanent bounce — email address is invalid or rejected'
|
||||
case 'complaint':
|
||||
return 'Spam complaint — recipient marked email as spam'
|
||||
case 'unsubscribe':
|
||||
return 'Recipient unsubscribed'
|
||||
default:
|
||||
return 'Email suppressed'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { createClient } from 'npm:@supabase/supabase-js@2'
|
||||
import { corsHeaders } from 'npm:@supabase/supabase-js@2/cors'
|
||||
|
||||
function jsonResponse(data: Record<string, unknown>, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
// Handle CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
if (req.method !== 'GET' && req.method !== 'POST') {
|
||||
return jsonResponse({ error: 'Method not allowed' }, 405)
|
||||
}
|
||||
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')
|
||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
return jsonResponse({ error: 'Server configuration error' }, 500)
|
||||
}
|
||||
|
||||
// Extract token from query params (GET) or body (POST)
|
||||
const url = new URL(req.url)
|
||||
let token: string | null = url.searchParams.get('token')
|
||||
|
||||
if (req.method === 'POST') {
|
||||
// Detect RFC 8058 one-click unsubscribe: POST with form-encoded body
|
||||
// containing "List-Unsubscribe=One-Click". Email clients (Gmail, Apple Mail,
|
||||
// etc.) send this when the user clicks "Unsubscribe" in the mail UI.
|
||||
const contentType = req.headers.get('content-type') ?? ''
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
const formText = await req.text()
|
||||
const params = new URLSearchParams(formText)
|
||||
// For one-click, token comes from query param (already set above).
|
||||
// Otherwise, token may be in the form body.
|
||||
if (!params.get('List-Unsubscribe')) {
|
||||
const formToken = params.get('token')
|
||||
if (formToken) {
|
||||
token = formToken
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// JSON body (from the app's unsubscribe page)
|
||||
try {
|
||||
const body = await req.json()
|
||||
if (body.token) {
|
||||
token = body.token
|
||||
}
|
||||
} catch {
|
||||
// Fall through — token stays from query param
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return jsonResponse({ error: 'Token is required' }, 400)
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
// Look up the token
|
||||
const { data: tokenRecord, error: lookupError } = await supabase
|
||||
.from('email_unsubscribe_tokens')
|
||||
.select('*')
|
||||
.eq('token', token)
|
||||
.maybeSingle()
|
||||
|
||||
if (lookupError || !tokenRecord) {
|
||||
return jsonResponse({ error: 'Invalid or expired token' }, 404)
|
||||
}
|
||||
|
||||
if (tokenRecord.used_at) {
|
||||
return jsonResponse({ valid: false, reason: 'already_unsubscribed' })
|
||||
}
|
||||
|
||||
// GET: Validate token (the app's unsubscribe page calls this on load)
|
||||
if (req.method === 'GET') {
|
||||
return jsonResponse({ valid: true })
|
||||
}
|
||||
|
||||
// POST: Process the unsubscribe
|
||||
// Atomic check-and-update to avoid TOCTOU race
|
||||
const { data: updated, error: updateError } = await supabase
|
||||
.from('email_unsubscribe_tokens')
|
||||
.update({ used_at: new Date().toISOString() })
|
||||
.eq('token', token)
|
||||
.is('used_at', null)
|
||||
.select()
|
||||
.maybeSingle()
|
||||
|
||||
if (updateError) {
|
||||
console.error('Failed to mark token as used', { error: updateError, token })
|
||||
return jsonResponse({ error: 'Failed to process unsubscribe' }, 500)
|
||||
}
|
||||
|
||||
if (!updated) {
|
||||
return jsonResponse({ success: false, reason: 'already_unsubscribed' })
|
||||
}
|
||||
|
||||
// Add email to suppressed list (upsert to handle duplicates)
|
||||
const { error: suppressError } = await supabase
|
||||
.from('suppressed_emails')
|
||||
.upsert(
|
||||
{ email: tokenRecord.email.toLowerCase(), reason: 'unsubscribe' },
|
||||
{ onConflict: 'email' },
|
||||
)
|
||||
|
||||
if (suppressError) {
|
||||
console.error('Failed to suppress email', {
|
||||
error: suppressError,
|
||||
email: tokenRecord.email,
|
||||
})
|
||||
return jsonResponse({ error: 'Failed to process unsubscribe' }, 500)
|
||||
}
|
||||
|
||||
console.log('Email unsubscribed', { email: tokenRecord.email })
|
||||
|
||||
return jsonResponse({ success: true })
|
||||
})
|
||||
@@ -0,0 +1,363 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.99.1";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function normalize(value: unknown) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
function normalizeEmail(value: unknown) {
|
||||
return normalize(value).toLowerCase();
|
||||
}
|
||||
|
||||
function isUuid(value: string) {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function buildActionEmailHtml({ title, intro, buttonLabel, actionLink }: { title: string; intro: string; buttonLabel: string; actionLink: string }) {
|
||||
return `
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
|
||||
<h2 style="color:#1e293b;margin-bottom:16px;">${escapeHtml(title)}</h2>
|
||||
<p style="color:#334155;font-size:15px;line-height:1.6;">${escapeHtml(intro)}</p>
|
||||
<div style="text-align:center;margin:28px 0;">
|
||||
<a href="${escapeHtml(actionLink)}" style="background-color:#0f172a;color:#ffffff;padding:12px 28px;text-decoration:none;border-radius:6px;font-weight:600;display:inline-block;font-size:15px;">${escapeHtml(buttonLabel)}</a>
|
||||
</div>
|
||||
<p style="color:#64748b;font-size:13px;">If you did not expect this email, you can safely ignore it.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function getAuthorizedCaller(req: Request, anonKey: string) {
|
||||
const authHeader = req.headers.get("authorization") || "";
|
||||
if (!authHeader.startsWith("Bearer ")) return { error: jsonResponse({ error: "Unauthorized" }, 401) };
|
||||
|
||||
const callerClient = createClient(Deno.env.get("SUPABASE_URL")!, anonKey, {
|
||||
global: { headers: { Authorization: authHeader } },
|
||||
});
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
const { data: claimsData, error: claimsError } = await callerClient.auth.getClaims(token);
|
||||
const callerId = claimsData?.claims?.sub;
|
||||
if (claimsError || !callerId) return { error: jsonResponse({ error: "Unauthorized" }, 401) };
|
||||
|
||||
const [{ data: isAdmin }, { data: isManager }] = await Promise.all([
|
||||
callerClient.rpc("has_role", { _user_id: callerId, _role: "admin" }),
|
||||
callerClient.rpc("has_role", { _user_id: callerId, _role: "manager" }),
|
||||
]);
|
||||
if (!isAdmin && !isManager) return { error: jsonResponse({ error: "Insufficient permissions" }, 403) };
|
||||
|
||||
return { callerClient, callerId, authHeader };
|
||||
}
|
||||
|
||||
async function getVerifiedSender(adminClient: any) {
|
||||
const { data, error } = await adminClient
|
||||
.from("email_senders")
|
||||
.select("id")
|
||||
.eq("is_active", true)
|
||||
.eq("verified", true)
|
||||
.order("is_default", { ascending: false })
|
||||
.order("updated_at", { ascending: false })
|
||||
.limit(1);
|
||||
if (error) return null;
|
||||
return data?.[0] ?? null;
|
||||
}
|
||||
|
||||
async function sendManagedEmail(callerClient: any, recipient: string, subject: string, html: string) {
|
||||
const adminClient = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);
|
||||
const sender = await getVerifiedSender(adminClient);
|
||||
if (!sender?.id) return { skipped: true, reason: "No verified email sender is configured" };
|
||||
|
||||
const { data, error } = await callerClient.functions.invoke("send-smtp-email", {
|
||||
body: { sender_id: sender.id, recipient: [recipient], subject, body: html, html, debug: false },
|
||||
});
|
||||
if (error) throw error;
|
||||
if (data?.success === false) throw new Error(data.error || "Email send failed");
|
||||
return { skipped: false };
|
||||
}
|
||||
|
||||
async function findMatchingOwner(adminClient: any, lastName: string, email: string, unitIdentifier: string, accountNumber: string) {
|
||||
const unitFilters = [];
|
||||
if (unitIdentifier) {
|
||||
unitFilters.push(`unit_number.ilike.${unitIdentifier}`);
|
||||
if (isUuid(unitIdentifier)) unitFilters.push(`id.eq.${unitIdentifier}`);
|
||||
}
|
||||
if (accountNumber) unitFilters.push(`account_number.ilike.${accountNumber}`);
|
||||
if (!unitFilters.length) return { unit: null, owner: null };
|
||||
|
||||
const { data: units, error: unitErr } = await adminClient
|
||||
.from("units")
|
||||
.select("id, association_id, unit_number, account_number")
|
||||
.or(unitFilters.join(","));
|
||||
if (unitErr) throw unitErr;
|
||||
if (!units?.length) return { unit: null, owner: null };
|
||||
|
||||
const unitIds = units.map((u: any) => u.id);
|
||||
const { data: owners, error: ownerErr } = await adminClient
|
||||
.from("owners")
|
||||
.select("id, first_name, last_name, email, association_id, unit_id, user_id, exclude_from_signin")
|
||||
.in("unit_id", unitIds)
|
||||
.eq("status", "active")
|
||||
.ilike("last_name", lastName);
|
||||
if (ownerErr) throw ownerErr;
|
||||
|
||||
const owner = (owners || []).find((o: any) => !email || normalizeEmail(o.email) === email) || owners?.[0] || null;
|
||||
const unit = owner ? units.find((u: any) => u.id === owner.unit_id) : units[0];
|
||||
return { unit, owner };
|
||||
}
|
||||
|
||||
async function createOrLinkOwnerAccount(adminClient: any, owner: any, email: string) {
|
||||
const fullName = `${owner.first_name || ""} ${owner.last_name || ""}`.trim();
|
||||
let userId = owner.user_id;
|
||||
|
||||
if (!userId) {
|
||||
const tempPassword = `Temp-${crypto.randomUUID()}!`;
|
||||
const { data: authData, error: createErr } = await adminClient.auth.admin.createUser({
|
||||
email,
|
||||
password: tempPassword,
|
||||
email_confirm: true,
|
||||
user_metadata: { full_name: fullName },
|
||||
});
|
||||
|
||||
if (createErr && createErr.message?.includes("already been registered")) {
|
||||
const { data: users } = await adminClient.auth.admin.listUsers();
|
||||
userId = users.users.find((u: any) => normalizeEmail(u.email) === email)?.id;
|
||||
} else if (createErr) {
|
||||
throw createErr;
|
||||
} else {
|
||||
userId = authData.user?.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!userId) throw new Error("Could not create or find user account");
|
||||
|
||||
await adminClient.from("owners").update({ user_id: userId, email }).eq("id", owner.id);
|
||||
|
||||
const { data: existingProfile } = await adminClient.from("profiles").select("user_id").eq("user_id", userId).maybeSingle();
|
||||
if (!existingProfile) await adminClient.from("profiles").insert({ user_id: userId, full_name: fullName, email });
|
||||
else await adminClient.from("profiles").update({ full_name: fullName, email }).eq("user_id", userId);
|
||||
|
||||
const { data: existingRole } = await adminClient.from("user_roles").select("id").eq("user_id", userId).eq("role", "homeowner").maybeSingle();
|
||||
if (!existingRole) await adminClient.from("user_roles").insert({ user_id: userId, role: "homeowner" });
|
||||
|
||||
return userId;
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
|
||||
const adminClient = createClient(supabaseUrl, serviceKey);
|
||||
const body = await req.json();
|
||||
const { action } = body;
|
||||
|
||||
if (action === "submit_request") {
|
||||
const lastName = normalize(body.last_name);
|
||||
const email = normalizeEmail(body.email);
|
||||
const unitIdentifier = normalize(body.unit_identifier || body.unit_number);
|
||||
const accountNumber = normalize(body.account_number);
|
||||
if (!lastName || !email || (!unitIdentifier && !accountNumber)) return jsonResponse({ error: "Last name, email, and either unit ID or account number are required" }, 400);
|
||||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return jsonResponse({ error: "Enter a valid email address" }, 400);
|
||||
|
||||
const { unit, owner } = await findMatchingOwner(adminClient, lastName, email, unitIdentifier, accountNumber);
|
||||
const { data, error } = await adminClient.from("owner_registration_requests").insert({
|
||||
last_name: lastName,
|
||||
email,
|
||||
unit_identifier: unitIdentifier,
|
||||
account_number: accountNumber,
|
||||
association_id: owner?.association_id || unit?.association_id || null,
|
||||
unit_id: owner?.unit_id || unit?.id || null,
|
||||
owner_id: owner?.id || null,
|
||||
notes: owner ? null : "No exact owner match was found automatically.",
|
||||
}).select("id").single();
|
||||
if (error) throw error;
|
||||
return jsonResponse({ success: true, id: data.id, matched: Boolean(owner) });
|
||||
}
|
||||
|
||||
if (action === "validate") {
|
||||
const unitNumber = normalize(body.unit_number);
|
||||
const accountNumber = normalize(body.account_number);
|
||||
if (!unitNumber || !accountNumber) return jsonResponse({ error: "Unit number and account number are required" }, 400);
|
||||
|
||||
const unitFilters = [`unit_number.ilike.${unitNumber}`];
|
||||
if (isUuid(unitNumber)) unitFilters.push(`id.eq.${unitNumber}`);
|
||||
|
||||
const { data: units, error: unitErr } = await adminClient
|
||||
.from("units")
|
||||
.select("id, unit_number, address, association_id, account_number")
|
||||
.or(unitFilters.join(","))
|
||||
.or(`account_number.ilike.${accountNumber}`);
|
||||
if (unitErr) throw unitErr;
|
||||
if (!units?.length) return jsonResponse({ error: "No matching unit found. Please check your Unit ID and Account Number." }, 404);
|
||||
|
||||
const { data: owners, error: ownerErr } = await adminClient
|
||||
.from("owners")
|
||||
.select("id, first_name, last_name, email, unit_id, association_id, user_id, exclude_from_signin")
|
||||
.in("unit_id", units.map((u: any) => u.id))
|
||||
.eq("status", "active");
|
||||
if (ownerErr) throw ownerErr;
|
||||
const available = (owners || []).filter((o: any) => !o.exclude_from_signin && !o.user_id);
|
||||
if (!available.length) return jsonResponse({ error: "All owners for this unit are already registered or excluded from sign-in." }, 400);
|
||||
return jsonResponse({ owners: available.map((o: any) => ({ id: o.id, first_name: o.first_name, last_name: o.last_name, email: o.email })), unit: units[0] });
|
||||
}
|
||||
|
||||
if (action === "register") {
|
||||
const email = normalizeEmail(body.email);
|
||||
const password = normalize(body.password);
|
||||
const ownerId = normalize(body.owner_id);
|
||||
if (!email || !password || !ownerId) return jsonResponse({ error: "Email, password, and owner selection are required" }, 400);
|
||||
|
||||
const { data: owner, error: ownerErr } = await adminClient
|
||||
.from("owners")
|
||||
.select("id, first_name, last_name, association_id, unit_id, user_id, exclude_from_signin")
|
||||
.eq("id", ownerId)
|
||||
.single();
|
||||
if (ownerErr || !owner) return jsonResponse({ error: "Owner record not found" }, 404);
|
||||
if (owner.user_id) return jsonResponse({ error: "This owner is already linked to an account" }, 400);
|
||||
if (owner.exclude_from_signin) return jsonResponse({ error: "This owner is excluded from sign-in" }, 400);
|
||||
|
||||
const userId = await createOrLinkOwnerAccount(adminClient, owner, email);
|
||||
const { error: updatePasswordError } = await adminClient.auth.admin.updateUserById(userId, { password });
|
||||
if (updatePasswordError) throw updatePasswordError;
|
||||
return jsonResponse({ success: true, user_id: userId });
|
||||
}
|
||||
|
||||
if (action === "request_password_reset") {
|
||||
const email = normalizeEmail(body.email);
|
||||
const origin = normalize(body.origin) || req.headers.get("origin") || "https://avria.cloud";
|
||||
if (!email) return jsonResponse({ error: "Email is required" }, 400);
|
||||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return jsonResponse({ error: "Enter a valid email address" }, 400);
|
||||
|
||||
const { data: owners, error: ownerErr } = await adminClient
|
||||
.from("owners")
|
||||
.select("id, first_name, last_name, email, association_id, unit_id, user_id, exclude_from_signin")
|
||||
.ilike("email", email)
|
||||
.eq("status", "active")
|
||||
.limit(1);
|
||||
if (ownerErr) throw ownerErr;
|
||||
|
||||
const owner = (owners || []).find((o: any) => !o.exclude_from_signin && normalizeEmail(o.email) === email);
|
||||
if (owner) {
|
||||
await createOrLinkOwnerAccount(adminClient, owner, email);
|
||||
const publicClient = createClient(supabaseUrl, anonKey);
|
||||
const { error: resetError } = await publicClient.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${origin.replace(/\/$/, "")}/reset-password`,
|
||||
});
|
||||
if (resetError) throw resetError;
|
||||
}
|
||||
|
||||
return jsonResponse({ success: true });
|
||||
}
|
||||
|
||||
if (action === "invite_owner" || action === "approve_request") {
|
||||
const auth = await getAuthorizedCaller(req, anonKey);
|
||||
if (auth.error) return auth.error;
|
||||
const callerClient = auth.callerClient!;
|
||||
const callerId = auth.callerId!;
|
||||
const origin = req.headers.get("origin") || "https://avria.cloud";
|
||||
let owner: any = null;
|
||||
let requestId: string | null = null;
|
||||
let email = normalizeEmail(body.email);
|
||||
|
||||
if (action === "approve_request") {
|
||||
requestId = normalize(body.request_id);
|
||||
if (!requestId) return jsonResponse({ error: "Request ID is required" }, 400);
|
||||
const { data: requestRow, error: reqErr } = await adminClient.from("owner_registration_requests").select("*").eq("id", requestId).single();
|
||||
if (reqErr || !requestRow) return jsonResponse({ error: "Registration request not found" }, 404);
|
||||
email = normalizeEmail(requestRow.email);
|
||||
if (requestRow.owner_id) {
|
||||
const { data } = await adminClient.from("owners").select("id, first_name, last_name, association_id, unit_id, user_id, exclude_from_signin").eq("id", requestRow.owner_id).single();
|
||||
owner = data;
|
||||
} else {
|
||||
const match = await findMatchingOwner(adminClient, requestRow.last_name, email, requestRow.unit_identifier, requestRow.account_number);
|
||||
owner = match.owner;
|
||||
}
|
||||
if (!owner) return jsonResponse({ error: "No matching owner was found for this request" }, 400);
|
||||
} else {
|
||||
const ownerId = normalize(body.owner_id);
|
||||
if (!ownerId) return jsonResponse({ error: "Owner ID is required" }, 400);
|
||||
const { data, error } = await adminClient.from("owners").select("id, first_name, last_name, email, association_id, unit_id, user_id, exclude_from_signin").eq("id", ownerId).single();
|
||||
if (error || !data) return jsonResponse({ error: "Owner record not found" }, 404);
|
||||
owner = data;
|
||||
email = normalizeEmail(body.email || owner.email);
|
||||
}
|
||||
|
||||
if (!email) return jsonResponse({ error: "Owner email is required before sending an invite" }, 400);
|
||||
if (owner.exclude_from_signin) return jsonResponse({ error: "This owner is excluded from sign-in" }, 400);
|
||||
|
||||
const userId = await createOrLinkOwnerAccount(adminClient, owner, email);
|
||||
const { data: linkData, error: linkError } = await adminClient.auth.admin.generateLink({
|
||||
type: "recovery",
|
||||
email,
|
||||
options: { redirectTo: `${origin.replace(/\/$/, "")}/reset-password` },
|
||||
});
|
||||
if (linkError) throw linkError;
|
||||
const actionLink = linkData?.properties?.action_link;
|
||||
if (!actionLink) throw new Error("Could not generate account setup link");
|
||||
|
||||
const html = buildActionEmailHtml({
|
||||
title: action === "approve_request" ? "Your account has been approved" : "You are invited to Avria Community Management",
|
||||
intro: action === "approve_request"
|
||||
? "Your homeowner portal account has been approved. Click below to choose your password and sign in."
|
||||
: "You have been invited to access your homeowner portal. Click below to choose your password and get started.",
|
||||
buttonLabel: "Choose Password",
|
||||
actionLink,
|
||||
});
|
||||
const sendResult = await sendManagedEmail(callerClient, email, action === "approve_request" ? "Your account has been approved" : "Homeowner portal invitation", html);
|
||||
|
||||
if (requestId) {
|
||||
await adminClient.from("owner_registration_requests").update({
|
||||
status: "approved",
|
||||
owner_id: owner.id,
|
||||
unit_id: owner.unit_id,
|
||||
association_id: owner.association_id,
|
||||
reviewed_by: callerId,
|
||||
reviewed_at: new Date().toISOString(),
|
||||
created_user_id: userId,
|
||||
}).eq("id", requestId);
|
||||
}
|
||||
|
||||
return jsonResponse({ success: true, user_id: userId, email_sent: !sendResult.skipped, action_link: sendResult.skipped ? actionLink : undefined, warning: sendResult.skipped ? sendResult.reason : undefined });
|
||||
}
|
||||
|
||||
if (action === "reject_request") {
|
||||
const auth = await getAuthorizedCaller(req, anonKey);
|
||||
if (auth.error) return auth.error;
|
||||
const requestId = normalize(body.request_id);
|
||||
if (!requestId) return jsonResponse({ error: "Request ID is required" }, 400);
|
||||
const { error } = await adminClient.from("owner_registration_requests").update({
|
||||
status: "rejected",
|
||||
notes: normalize(body.notes) || null,
|
||||
reviewed_by: auth.callerId,
|
||||
reviewed_at: new Date().toISOString(),
|
||||
}).eq("id", requestId);
|
||||
if (error) throw error;
|
||||
return jsonResponse({ success: true });
|
||||
}
|
||||
|
||||
return jsonResponse({ error: "Invalid action" }, 400);
|
||||
} catch (err: any) {
|
||||
console.error("homeowner-signup error:", err);
|
||||
return jsonResponse({ error: err.message || "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4";
|
||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
// CSV parser that handles multiline quoted fields
|
||||
function parseCSV(text: string): Record<string, string>[] {
|
||||
const rows: string[][] = [];
|
||||
let currentRow: string[] = [];
|
||||
let currentField = "";
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const nextChar = text[i + 1];
|
||||
|
||||
if (inQuotes) {
|
||||
if (char === '"' && nextChar === '"') {
|
||||
currentField += '"';
|
||||
i++; // skip escaped quote
|
||||
} else if (char === '"') {
|
||||
inQuotes = false;
|
||||
} else {
|
||||
currentField += char;
|
||||
}
|
||||
} else {
|
||||
if (char === '"') {
|
||||
inQuotes = true;
|
||||
} else if (char === ',') {
|
||||
currentRow.push(currentField);
|
||||
currentField = "";
|
||||
} else if (char === '\n' || (char === '\r' && nextChar === '\n')) {
|
||||
currentRow.push(currentField);
|
||||
currentField = "";
|
||||
rows.push(currentRow);
|
||||
currentRow = [];
|
||||
if (char === '\r') i++; // skip \n after \r
|
||||
} else {
|
||||
currentField += char;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Push last field and row
|
||||
if (currentField || currentRow.length > 0) {
|
||||
currentRow.push(currentField);
|
||||
rows.push(currentRow);
|
||||
}
|
||||
|
||||
if (rows.length < 2) return [];
|
||||
|
||||
const headers = rows[0].map(h => h.trim());
|
||||
const results: Record<string, string>[] = [];
|
||||
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
if (rows[i].length < 2) continue; // skip empty rows
|
||||
const obj: Record<string, string> = {};
|
||||
for (let j = 0; j < headers.length; j++) {
|
||||
obj[headers[j]] = rows[i][j] ?? "";
|
||||
}
|
||||
results.push(obj);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function tryParseJSON(str: string): any {
|
||||
if (!str || str === "[]" || str === "") return null;
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, serviceRoleKey);
|
||||
|
||||
// Accept CSV either as body or fetch from a URL param
|
||||
const url = new URL(req.url);
|
||||
const csvUrl = url.searchParams.get("csv_url");
|
||||
let csvText: string;
|
||||
if (csvUrl) {
|
||||
const resp = await fetch(csvUrl);
|
||||
csvText = await resp.text();
|
||||
} else {
|
||||
csvText = await req.text();
|
||||
}
|
||||
const records = parseCSV(csvText);
|
||||
|
||||
console.log(`Parsed ${records.length} violation records from CSV`);
|
||||
|
||||
let inserted = 0;
|
||||
let errors: string[] = [];
|
||||
|
||||
for (const row of records) {
|
||||
// Map priority values
|
||||
let priority = row.priority || null;
|
||||
if (priority === 'normal') priority = 'medium';
|
||||
|
||||
const violation = {
|
||||
id: row.id || undefined,
|
||||
association_id: row.client_id,
|
||||
title: row.violation_type || "Untitled Violation",
|
||||
address: row.address || null,
|
||||
violation_type: row.violation_type || null,
|
||||
status: row.status || null,
|
||||
photo_url: row.photo_url || null,
|
||||
notes: row.notes || null,
|
||||
created_at: row.created_at || undefined,
|
||||
updated_at: row.updated_at || undefined,
|
||||
violation_date: row.violation_date || null,
|
||||
due_date: row.due_date || null,
|
||||
property_id: row.property_id || null,
|
||||
priority: priority,
|
||||
assigned_to: row.assigned_to || null,
|
||||
notice_level: row.notice_level || null,
|
||||
notice_history: tryParseJSON(row.notice_history),
|
||||
stage: row.stage || null,
|
||||
timeline_entries: tryParseJSON(row.timeline_entries),
|
||||
photo_urls: tryParseJSON(row.photo_urls),
|
||||
description: row.description || null,
|
||||
created_by: null, // Skip created_by to avoid FK issues
|
||||
unit_id: row.unit_id || null,
|
||||
};
|
||||
|
||||
// Remove empty string keys that should be null/undefined
|
||||
if (!violation.property_id) delete (violation as any).property_id;
|
||||
if (!violation.assigned_to) delete (violation as any).assigned_to;
|
||||
if (!violation.created_by) delete (violation as any).created_by;
|
||||
if (!violation.unit_id) delete (violation as any).unit_id;
|
||||
if (!violation.due_date) delete (violation as any).due_date;
|
||||
|
||||
const { error } = await supabase
|
||||
.from("violations")
|
||||
.upsert(violation, { onConflict: "id" });
|
||||
|
||||
if (error) {
|
||||
console.error(`Error inserting ${row.id}: ${error.message}`);
|
||||
errors.push(`${row.id}: ${error.message}`);
|
||||
} else {
|
||||
inserted++;
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
total: records.length,
|
||||
inserted,
|
||||
errors: errors.length,
|
||||
errorDetails: errors.slice(0, 10)
|
||||
}),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Import error:", err);
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: err.message }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
// SendGrid Inbound Parse webhook handler.
|
||||
// Receives multipart/form-data POST from SendGrid when an email arrives at
|
||||
// the configured inbound hostname (e.g. bills.stagelaw.com).
|
||||
// Looks up the sender in vendor_email_mappings; if matched, parses the PDF
|
||||
// attachment via parse-invoice and creates a draft bill. Otherwise quarantines
|
||||
// the email in inbound_bill_emails for manual review.
|
||||
|
||||
import { createClient } from 'npm:@supabase/supabase-js@2'
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
}
|
||||
|
||||
function extractEmail(raw: string | null): { email: string | null; name: string | null } {
|
||||
if (!raw) return { email: null, name: null }
|
||||
// Match "Name <email@x.com>" or just "email@x.com"
|
||||
const m = raw.match(/(?:"?([^"<]*)"?\s*)?<?([^\s<>]+@[^\s<>]+)>?/)
|
||||
if (!m) return { email: null, name: null }
|
||||
return { email: m[2].trim().toLowerCase(), name: (m[1] || '').trim() || null }
|
||||
}
|
||||
|
||||
async function arrayBufferToBase64(buf: ArrayBuffer): Promise<string> {
|
||||
const bytes = new Uint8Array(buf)
|
||||
let binary = ''
|
||||
const chunk = 0x8000
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + chunk))
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') return new Response(null, { headers: corsHeaders })
|
||||
if (req.method !== 'POST') {
|
||||
return new Response('Method not allowed', { status: 405, headers: corsHeaders })
|
||||
}
|
||||
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!
|
||||
const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||||
const supabase = createClient(supabaseUrl, serviceKey)
|
||||
|
||||
let formData: FormData
|
||||
try {
|
||||
formData = await req.formData()
|
||||
} catch (err) {
|
||||
console.error('Failed to parse form data', err)
|
||||
return new Response('Bad Request', { status: 400, headers: corsHeaders })
|
||||
}
|
||||
|
||||
const fromRaw = formData.get('from')?.toString() ?? null
|
||||
const toRaw = formData.get('to')?.toString() ?? null
|
||||
const subject = formData.get('subject')?.toString() ?? null
|
||||
const text = formData.get('text')?.toString() ?? null
|
||||
const attachmentCount = parseInt(formData.get('attachments')?.toString() ?? '0', 10)
|
||||
|
||||
const { email: fromEmail, name: fromName } = extractEmail(fromRaw)
|
||||
const { email: toEmail } = extractEmail(toRaw)
|
||||
|
||||
console.log('Inbound bill email', { fromEmail, toEmail, subject, attachmentCount })
|
||||
|
||||
// ===== Deduplication =====
|
||||
// External mail loops (auto-forwarders, retry policies) can re-deliver the exact
|
||||
// same email repeatedly. If we already received a message with the same sender +
|
||||
// subject in the last 30 days, treat this delivery as a duplicate and short-circuit.
|
||||
if (fromEmail && subject) {
|
||||
const sinceIso = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
|
||||
const { data: existing, error: dupErr } = await supabase
|
||||
.from('inbound_bill_emails')
|
||||
.select('id, status, bill_id, created_at')
|
||||
.eq('from_email', fromEmail)
|
||||
.eq('subject', subject)
|
||||
.gte('created_at', sinceIso)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
if (dupErr) {
|
||||
console.error('Dedup lookup failed', dupErr)
|
||||
} else if (existing && existing.length > 0) {
|
||||
console.log('Duplicate inbound email ignored', {
|
||||
fromEmail, subject, existingId: existing[0].id, existingStatus: existing[0].status,
|
||||
})
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, deduplicated: true, existingId: existing[0].id }),
|
||||
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Find first PDF attachment
|
||||
let pdfFile: File | null = null
|
||||
for (let i = 1; i <= attachmentCount; i++) {
|
||||
const file = formData.get(`attachment${i}`) as File | null
|
||||
if (file && (file.type === 'application/pdf' || file.name?.toLowerCase().endsWith('.pdf'))) {
|
||||
pdfFile = file
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Build raw payload snapshot (small fields only)
|
||||
const rawPayload: Record<string, unknown> = {
|
||||
from: fromRaw, to: toRaw, subject,
|
||||
attachments: attachmentCount,
|
||||
envelope: formData.get('envelope')?.toString() ?? null,
|
||||
spam_score: formData.get('spam_score')?.toString() ?? null,
|
||||
}
|
||||
|
||||
// Upload attachment to storage if present
|
||||
let storagePath: string | null = null
|
||||
let attachmentUrl: string | null = null
|
||||
let pdfBase64: string | null = null
|
||||
if (pdfFile) {
|
||||
const buf = await pdfFile.arrayBuffer()
|
||||
pdfBase64 = await arrayBufferToBase64(buf)
|
||||
const safeName = (pdfFile.name || 'invoice.pdf').replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||
storagePath = `${new Date().toISOString().slice(0, 10)}/${crypto.randomUUID()}-${safeName}`
|
||||
const { error: uploadErr } = await supabase.storage
|
||||
.from('inbound-bill-attachments')
|
||||
.upload(storagePath, buf, { contentType: 'application/pdf', upsert: false })
|
||||
if (uploadErr) {
|
||||
console.error('Failed to upload attachment', uploadErr)
|
||||
storagePath = null
|
||||
} else {
|
||||
const { data: signed } = await supabase.storage
|
||||
.from('inbound-bill-attachments')
|
||||
.createSignedUrl(storagePath, 60 * 60 * 24 * 30) // 30 days
|
||||
attachmentUrl = signed?.signedUrl ?? null
|
||||
}
|
||||
}
|
||||
|
||||
// Look up sender → association mapping
|
||||
let mappedAssociationId: string | null = null
|
||||
let mappedVendorId: string | null = null
|
||||
if (fromEmail) {
|
||||
const { data: mappings } = await supabase
|
||||
.from('vendor_email_mappings')
|
||||
.select('association_id, vendor_id')
|
||||
.eq('email', fromEmail)
|
||||
.limit(1)
|
||||
if (mappings && mappings.length > 0) {
|
||||
mappedAssociationId = mappings[0].association_id as string
|
||||
mappedVendorId = (mappings[0].vendor_id as string) ?? null
|
||||
}
|
||||
}
|
||||
|
||||
// Always create the inbox row first so nothing is lost
|
||||
const { data: inboxRow, error: inboxErr } = await supabase
|
||||
.from('inbound_bill_emails')
|
||||
.insert({
|
||||
from_email: fromEmail,
|
||||
from_name: fromName,
|
||||
to_email: toEmail,
|
||||
subject,
|
||||
body_text: text?.slice(0, 10000) ?? null,
|
||||
attachment_url: attachmentUrl,
|
||||
attachment_filename: pdfFile?.name ?? null,
|
||||
attachment_storage_path: storagePath,
|
||||
association_id: mappedAssociationId,
|
||||
raw_payload: rawPayload,
|
||||
status: 'unassigned',
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (inboxErr || !inboxRow) {
|
||||
console.error('Failed to create inbox row', inboxErr)
|
||||
return new Response('Internal error', { status: 500, headers: corsHeaders })
|
||||
}
|
||||
|
||||
const inboxId = inboxRow.id as string
|
||||
|
||||
// No PDF? Leave as unassigned with note
|
||||
if (!pdfBase64) {
|
||||
await supabase
|
||||
.from('inbound_bill_emails')
|
||||
.update({ status: 'unassigned', error_message: 'No PDF attachment found' })
|
||||
.eq('id', inboxId)
|
||||
return new Response(JSON.stringify({ ok: true, inboxId, reason: 'no_pdf' }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
// Unknown sender → leave for manual review (with the PDF saved + parsed for convenience)
|
||||
// We still try to parse so staff can preview the extracted data
|
||||
let parsed: Record<string, unknown> | null = null
|
||||
let parseError: string | null = null
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('parse-invoice', {
|
||||
body: { pdf_base64: pdfBase64, filename: pdfFile?.name ?? 'invoice.pdf' },
|
||||
})
|
||||
if (error) throw error
|
||||
parsed = (data as { data?: Record<string, unknown> })?.data ?? null
|
||||
} catch (err) {
|
||||
parseError = err instanceof Error ? err.message : String(err)
|
||||
console.error('parse-invoice failed', parseError)
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('inbound_bill_emails')
|
||||
.update({ parsed_data: parsed, error_message: parseError })
|
||||
.eq('id', inboxId)
|
||||
|
||||
// If we have a mapping AND we parsed successfully, auto-create a draft bill
|
||||
if (mappedAssociationId && parsed) {
|
||||
const totalAmount = Number(parsed.total_amount) || 0
|
||||
const invoiceNumber = (parsed.invoice_number as string) || null
|
||||
const invoiceDate = (parsed.invoice_date as string) || new Date().toISOString().slice(0, 10)
|
||||
const dueDate = (parsed.due_date as string) || null
|
||||
const vendorName = (parsed.vendor_name as string) || fromName || fromEmail || 'Unknown Vendor'
|
||||
const lineItems = Array.isArray((parsed as any).line_items) ? (parsed as any).line_items : []
|
||||
|
||||
// Upload the PDF to the public `invoices` bucket so the URL is permanent
|
||||
// (the inbound-bill-attachments bucket is private and signed URLs expire).
|
||||
let publicPdfUrl: string | null = null
|
||||
try {
|
||||
const safeName = (pdfFile?.name || 'invoice.pdf').replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||
const invPath = `inbound/${new Date().toISOString().slice(0, 10)}/${crypto.randomUUID()}-${safeName}`
|
||||
const buf = Uint8Array.from(atob(pdfBase64), (c) => c.charCodeAt(0))
|
||||
const { error: invUploadErr } = await supabase.storage
|
||||
.from('invoices')
|
||||
.upload(invPath, buf, { contentType: 'application/pdf', upsert: false })
|
||||
if (invUploadErr) {
|
||||
console.error('Failed to upload PDF to invoices bucket', invUploadErr)
|
||||
} else {
|
||||
const { data: pub } = supabase.storage.from('invoices').getPublicUrl(invPath)
|
||||
publicPdfUrl = pub?.publicUrl ?? null
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Public PDF upload error', err)
|
||||
}
|
||||
|
||||
const billAttachmentUrl = publicPdfUrl ?? attachmentUrl
|
||||
|
||||
// Create a linked invoices row (mirrors AI Bill Parser flow) so Bill Approvals
|
||||
// can read raw_pdf_url and display the attachment.
|
||||
let sourceInvoiceId: string | null = null
|
||||
try {
|
||||
const { data: invRow, error: invErr } = await supabase
|
||||
.from('invoices')
|
||||
.insert({
|
||||
association_id: mappedAssociationId,
|
||||
vendor_name: vendorName,
|
||||
invoice_number: invoiceNumber,
|
||||
issue_date: invoiceDate,
|
||||
due_date: dueDate,
|
||||
amount: totalAmount,
|
||||
description: `Inbound from ${vendorName} (${subject ?? 'no subject'})`,
|
||||
status: 'pending',
|
||||
raw_pdf_url: billAttachmentUrl,
|
||||
line_items: lineItems,
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
if (invErr) {
|
||||
console.error('Failed to create invoice row', invErr)
|
||||
} else {
|
||||
sourceInvoiceId = invRow?.id ?? null
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Invoice insert error', err)
|
||||
}
|
||||
|
||||
const { data: bill, error: billErr } = await supabase
|
||||
.from('bills')
|
||||
.insert({
|
||||
association_id: mappedAssociationId,
|
||||
vendor_id: mappedVendorId,
|
||||
amount: totalAmount,
|
||||
invoice_number: invoiceNumber,
|
||||
bill_date: invoiceDate,
|
||||
due_date: dueDate,
|
||||
description: `Inbound from ${vendorName} (${subject ?? 'no subject'})`,
|
||||
attachment_url: billAttachmentUrl,
|
||||
source_invoice_id: sourceInvoiceId,
|
||||
status: 'pending',
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (billErr) {
|
||||
console.error('Failed to create bill', billErr)
|
||||
await supabase
|
||||
.from('inbound_bill_emails')
|
||||
.update({ status: 'error', error_message: `Bill creation failed: ${billErr.message}` })
|
||||
.eq('id', inboxId)
|
||||
} else {
|
||||
await supabase
|
||||
.from('inbound_bill_emails')
|
||||
.update({
|
||||
status: 'processed',
|
||||
bill_id: bill.id,
|
||||
processed_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', inboxId)
|
||||
console.log('Created bill from inbound email', { billId: bill.id, inboxId })
|
||||
}
|
||||
}
|
||||
|
||||
// Notify staff (admins + managers) of the inbound bill
|
||||
try {
|
||||
const { data: staffRoles } = await supabase
|
||||
.from('user_roles')
|
||||
.select('user_id')
|
||||
.in('role', ['admin', 'manager'])
|
||||
|
||||
const uniqueIds = Array.from(
|
||||
new Set((staffRoles ?? []).map((r: any) => r.user_id).filter(Boolean))
|
||||
)
|
||||
|
||||
if (uniqueIds.length > 0) {
|
||||
const senderLabel = fromName || fromEmail || 'Unknown sender'
|
||||
const subjectLabel = subject ? `: ${subject}` : ''
|
||||
const title = mappedAssociationId
|
||||
? 'New inbound bill received'
|
||||
: 'Inbound bill needs review'
|
||||
const message = mappedAssociationId
|
||||
? `Bill from ${senderLabel}${subjectLabel} was received and processed.`
|
||||
: `Email from ${senderLabel}${subjectLabel} could not be matched to a vendor and needs manual review.`
|
||||
const link = '/dashboard/inbound-bills'
|
||||
|
||||
const notifications = uniqueIds.map((uid) => ({
|
||||
user_id: uid,
|
||||
type: 'inbound_bill_received',
|
||||
title,
|
||||
message,
|
||||
related_item_id: inboxId,
|
||||
related_item_type: 'inbound_bill_email',
|
||||
link,
|
||||
}))
|
||||
|
||||
const { error: notifErr } = await supabase
|
||||
.from('in_app_notifications')
|
||||
.insert(notifications)
|
||||
if (notifErr) console.error('Failed to insert notifications', notifErr)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Notification dispatch failed', err)
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ ok: true, inboxId }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.99.1";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
const PRODUCTION_ORIGIN = "https://avria.cloud";
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
|
||||
|
||||
try {
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
||||
const SERVICE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY")!;
|
||||
|
||||
const authHeader = req.headers.get("Authorization") || "";
|
||||
if (!authHeader) {
|
||||
return new Response(JSON.stringify({ error: "Missing auth" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
// Verify caller is staff
|
||||
const userClient = createClient(SUPABASE_URL, ANON_KEY, { global: { headers: { Authorization: authHeader } } });
|
||||
const { data: { user: caller } } = await userClient.auth.getUser();
|
||||
if (!caller) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
const admin = createClient(SUPABASE_URL, SERVICE_KEY);
|
||||
const { data: roles } = await admin.from("user_roles").select("role").eq("user_id", caller.id);
|
||||
const isStaff = (roles || []).some((r: any) => r.role === "admin" || r.role === "manager");
|
||||
if (!isStaff) {
|
||||
return new Response(JSON.stringify({ error: "Staff only" }), { status: 403, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { rental_id, email, as_owner } = body || {};
|
||||
const isOwnerInvite = !!as_owner;
|
||||
if (!rental_id || !email) {
|
||||
return new Response(JSON.stringify({ error: "rental_id and email required" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
// Look up rental
|
||||
const { data: rental, error: rentalErr } = await admin
|
||||
.from("rv_boat_lot_rentals")
|
||||
.select("id, renter_name, association_id, user_id")
|
||||
.eq("id", rental_id)
|
||||
.single();
|
||||
if (rentalErr || !rental) {
|
||||
return new Response(JSON.stringify({ error: "Rental not found" }), { status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
// Find or create user
|
||||
let userId: string | null = null;
|
||||
const { data: existingList } = await admin.auth.admin.listUsers({ page: 1, perPage: 1, filter: `email.eq.${email}` } as any);
|
||||
// Fallback: paginate-search if filter unsupported
|
||||
if (existingList?.users?.length) {
|
||||
userId = existingList.users[0].id;
|
||||
} else {
|
||||
// Try fetching all (small project) then match
|
||||
const { data: all } = await admin.auth.admin.listUsers({ page: 1, perPage: 1000 });
|
||||
const found = all?.users?.find((u) => (u.email || "").toLowerCase() === String(email).toLowerCase());
|
||||
if (found) userId = found.id;
|
||||
}
|
||||
|
||||
let invited = false;
|
||||
if (!userId) {
|
||||
const { data: created, error: createErr } = await admin.auth.admin.inviteUserByEmail(email, {
|
||||
redirectTo: `${PRODUCTION_ORIGIN}/reset-password?mode=invite`,
|
||||
data: { full_name: rental.renter_name },
|
||||
});
|
||||
if (createErr || !created?.user) {
|
||||
return new Response(JSON.stringify({ error: createErr?.message || "Failed to invite" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
userId = created.user.id;
|
||||
invited = true;
|
||||
} else {
|
||||
// Send password recovery so existing users can also access portal
|
||||
await admin.auth.admin.generateLink({ type: "recovery", email, options: { redirectTo: `${PRODUCTION_ORIGIN}/reset-password` } } as any);
|
||||
}
|
||||
|
||||
// Assign the homeowner-style RV/Boat Lot role, idempotent
|
||||
const role = "rv_boat_lot";
|
||||
await admin.from("user_roles").upsert(
|
||||
{ user_id: userId, role: role as any },
|
||||
{ onConflict: "user_id,role" } as any,
|
||||
);
|
||||
|
||||
// Link rental to user (and flag is_owner if this is an owner invite)
|
||||
const updatePayload: Record<string, unknown> = { user_id: userId, renter_email: email };
|
||||
if (isOwnerInvite) updatePayload.is_owner = true;
|
||||
const { error: linkErr } = await admin
|
||||
.from("rv_boat_lot_rentals")
|
||||
.update(updatePayload)
|
||||
.eq("id", rental_id);
|
||||
if (linkErr) {
|
||||
return new Response(JSON.stringify({ error: linkErr.message }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ ok: true, user_id: userId, invited }), {
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (e: any) {
|
||||
return new Response(JSON.stringify({ error: e?.message || "Server error" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
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',
|
||||
};
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const authHeader = req.headers.get('Authorization');
|
||||
if (!authHeader) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_ANON_KEY')!,
|
||||
{ global: { headers: { Authorization: authHeader } } }
|
||||
);
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { action, api_key, server_prefix, audience_name, association_name, from_name, from_email } = body;
|
||||
|
||||
if (!api_key || !server_prefix) {
|
||||
return new Response(JSON.stringify({ error: 'Missing api_key or server_prefix' }), {
|
||||
status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const baseUrl = `https://${server_prefix}.api.mailchimp.com/3.0`;
|
||||
const authHeaderMc = `Basic ${btoa(`anystring:${api_key}`)}`;
|
||||
|
||||
if (action === 'ping') {
|
||||
const r = await fetch(`${baseUrl}/ping`, { headers: { Authorization: authHeaderMc } });
|
||||
const data = await r.json();
|
||||
if (!r.ok) {
|
||||
return new Response(JSON.stringify({ success: false, error: data.detail || 'Ping failed' }), {
|
||||
status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ success: true, data }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (action === 'list') {
|
||||
const r = await fetch(`${baseUrl}/lists?count=100`, { headers: { Authorization: authHeaderMc } });
|
||||
const data = await r.json();
|
||||
if (!r.ok) {
|
||||
return new Response(JSON.stringify({ success: false, error: data.detail || 'List failed' }), {
|
||||
status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ success: true, lists: data.lists || [] }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (action === 'create') {
|
||||
const payload = {
|
||||
name: audience_name || association_name || 'HOA Owners',
|
||||
contact: {
|
||||
company: association_name || 'HOA',
|
||||
address1: 'N/A',
|
||||
city: 'N/A',
|
||||
state: 'N/A',
|
||||
zip: '00000',
|
||||
country: 'US',
|
||||
},
|
||||
permission_reminder: 'You are receiving this because you are an owner in this association.',
|
||||
campaign_defaults: {
|
||||
from_name: from_name || association_name || 'HOA',
|
||||
from_email: from_email || user.email || 'noreply@example.com',
|
||||
subject: '',
|
||||
language: 'en',
|
||||
},
|
||||
email_type_option: false,
|
||||
};
|
||||
|
||||
const r = await fetch(`${baseUrl}/lists`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: authHeaderMc, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) {
|
||||
return new Response(JSON.stringify({ success: false, error: data.detail || data.title || 'Create failed' }), {
|
||||
status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ success: true, list: data }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ error: 'Unknown action' }), {
|
||||
status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (err) {
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||
|
||||
try {
|
||||
const { association_id, subject, from_name, reply_to, html, send_now } = await req.json();
|
||||
if (!association_id || !subject || !from_name || !reply_to || !html) {
|
||||
return new Response(JSON.stringify({ success: false, error: "Missing required fields." }), {
|
||||
status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
|
||||
);
|
||||
|
||||
const { data: config, error: cfgErr } = await supabase
|
||||
.from("mailchimp_configs")
|
||||
.select("*")
|
||||
.eq("association_id", association_id)
|
||||
.maybeSingle();
|
||||
|
||||
if (cfgErr) throw cfgErr;
|
||||
if (!config?.api_key || !config?.server_prefix || !config?.audience_id) {
|
||||
return new Response(JSON.stringify({ success: false, error: "Mailchimp is not configured for this association." }), {
|
||||
status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const baseUrl = `https://${config.server_prefix}.api.mailchimp.com/3.0`;
|
||||
const auth = "Basic " + btoa(`anystring:${config.api_key}`);
|
||||
|
||||
// 1. Create the campaign
|
||||
const createRes = await fetch(`${baseUrl}/campaigns`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: auth, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "regular",
|
||||
recipients: { list_id: config.audience_id },
|
||||
settings: {
|
||||
subject_line: subject,
|
||||
title: `${subject} - ${new Date().toISOString()}`,
|
||||
from_name,
|
||||
reply_to,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const campaign = await createRes.json();
|
||||
if (!createRes.ok) {
|
||||
return new Response(JSON.stringify({ success: false, error: `Create failed: ${campaign.detail || campaign.title || "Unknown"}` }), {
|
||||
status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Set the content
|
||||
const contentRes = await fetch(`${baseUrl}/campaigns/${campaign.id}/content`, {
|
||||
method: "PUT",
|
||||
headers: { Authorization: auth, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ html }),
|
||||
});
|
||||
if (!contentRes.ok) {
|
||||
const errBody = await contentRes.json().catch(() => ({}));
|
||||
return new Response(JSON.stringify({ success: false, error: `Content failed: ${errBody.detail || "Unknown"}`, campaign_id: campaign.id }), {
|
||||
status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Optionally send immediately
|
||||
if (send_now) {
|
||||
const sendRes = await fetch(`${baseUrl}/campaigns/${campaign.id}/actions/send`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: auth },
|
||||
});
|
||||
if (!sendRes.ok) {
|
||||
const errBody = await sendRes.json().catch(() => ({}));
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: `Send failed: ${errBody.detail || errBody.title || "Unknown"}. Campaign saved as draft in Mailchimp.`,
|
||||
campaign_id: campaign.id,
|
||||
web_id: campaign.web_id,
|
||||
}), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
campaign_id: campaign.id,
|
||||
web_id: campaign.web_id,
|
||||
sent: !!send_now,
|
||||
recipient_count: campaign.recipients?.recipient_count ?? null,
|
||||
}), { headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
} catch (e: any) {
|
||||
return new Response(JSON.stringify({ success: false, error: e.message || String(e) }), {
|
||||
status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
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',
|
||||
};
|
||||
|
||||
async function md5Lower(s: string) {
|
||||
const data = new TextEncoder().encode(s.toLowerCase());
|
||||
// Mailchimp requires MD5 of lowercase email as the subscriber hash
|
||||
const hash = await crypto.subtle.digest('MD5', data).catch(() => null);
|
||||
if (hash) {
|
||||
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
// Fallback: use a JS MD5 implementation
|
||||
return jsMd5(s.toLowerCase());
|
||||
}
|
||||
|
||||
// Minimal JS MD5 fallback (Deno's WebCrypto may not support MD5 in some runtimes)
|
||||
function jsMd5(str: string): string {
|
||||
function rotateLeft(x: number, n: number) { return (x << n) | (x >>> (32 - n)); }
|
||||
function addUnsigned(x: number, y: number) {
|
||||
const x4 = (x & 0x40000000); const y4 = (y & 0x40000000);
|
||||
const x8 = (x & 0x80000000); const y8 = (y & 0x80000000);
|
||||
const result = (x & 0x3FFFFFFF) + (y & 0x3FFFFFFF);
|
||||
if (x4 & y4) return (result ^ 0x80000000 ^ x8 ^ y8);
|
||||
if (x4 | y4) {
|
||||
if (result & 0x40000000) return (result ^ 0xC0000000 ^ x8 ^ y8);
|
||||
else return (result ^ 0x40000000 ^ x8 ^ y8);
|
||||
} else return (result ^ x8 ^ y8);
|
||||
}
|
||||
function f(x:number,y:number,z:number){return (x&y)|((~x)&z);}
|
||||
function g(x:number,y:number,z:number){return (x&z)|(y&(~z));}
|
||||
function h(x:number,y:number,z:number){return x^y^z;}
|
||||
function i(x:number,y:number,z:number){return y^(x|(~z));}
|
||||
function ff(a:number,b:number,c:number,d:number,x:number,s:number,ac:number){a=addUnsigned(a,addUnsigned(addUnsigned(f(b,c,d),x),ac));return addUnsigned(rotateLeft(a,s),b);}
|
||||
function gg(a:number,b:number,c:number,d:number,x:number,s:number,ac:number){a=addUnsigned(a,addUnsigned(addUnsigned(g(b,c,d),x),ac));return addUnsigned(rotateLeft(a,s),b);}
|
||||
function hh(a:number,b:number,c:number,d:number,x:number,s:number,ac:number){a=addUnsigned(a,addUnsigned(addUnsigned(h(b,c,d),x),ac));return addUnsigned(rotateLeft(a,s),b);}
|
||||
function ii(a:number,b:number,c:number,d:number,x:number,s:number,ac:number){a=addUnsigned(a,addUnsigned(addUnsigned(i(b,c,d),x),ac));return addUnsigned(rotateLeft(a,s),b);}
|
||||
function convertToWordArray(str:string){
|
||||
let lWordCount; const lMessageLength=str.length;
|
||||
const lNumberOfWordsTempOne=lMessageLength+8;
|
||||
const lNumberOfWordsTempTwo=(lNumberOfWordsTempOne-(lNumberOfWordsTempOne%64))/64;
|
||||
const lNumberOfWords=(lNumberOfWordsTempTwo+1)*16;
|
||||
const lWordArray=new Array(lNumberOfWords-1).fill(0);
|
||||
let lBytePosition=0; let lByteCount=0;
|
||||
while(lByteCount<lMessageLength){
|
||||
lWordCount=(lByteCount-(lByteCount%4))/4;
|
||||
lBytePosition=(lByteCount%4)*8;
|
||||
lWordArray[lWordCount]=(lWordArray[lWordCount]|(str.charCodeAt(lByteCount)<<lBytePosition));
|
||||
lByteCount++;
|
||||
}
|
||||
lWordCount=(lByteCount-(lByteCount%4))/4;
|
||||
lBytePosition=(lByteCount%4)*8;
|
||||
lWordArray[lWordCount]=lWordArray[lWordCount]|(0x80<<lBytePosition);
|
||||
lWordArray[lNumberOfWords-2]=lMessageLength<<3;
|
||||
lWordArray[lNumberOfWords-1]=lMessageLength>>>29;
|
||||
return lWordArray;
|
||||
}
|
||||
function wordToHex(lValue:number){let wordToHexValue="",wordToHexValueTemp="",lByte,lCount;for(lCount=0;lCount<=3;lCount++){lByte=(lValue>>>(lCount*8))&255;wordToHexValueTemp="0"+lByte.toString(16);wordToHexValue=wordToHexValue+wordToHexValueTemp.substr(wordToHexValueTemp.length-2,2);}return wordToHexValue;}
|
||||
function utf8Encode(str:string){return unescape(encodeURIComponent(str));}
|
||||
const x=convertToWordArray(utf8Encode(str));
|
||||
let a=0x67452301,b=0xEFCDAB89,c=0x98BADCFE,d=0x10325476;
|
||||
const S11=7,S12=12,S13=17,S14=22,S21=5,S22=9,S23=14,S24=20,S31=4,S32=11,S33=16,S34=23,S41=6,S42=10,S43=15,S44=21;
|
||||
for(let k=0;k<x.length;k+=16){
|
||||
const AA=a,BB=b,CC=c,DD=d;
|
||||
a=ff(a,b,c,d,x[k+0],S11,0xD76AA478); d=ff(d,a,b,c,x[k+1],S12,0xE8C7B756); c=ff(c,d,a,b,x[k+2],S13,0x242070DB); b=ff(b,c,d,a,x[k+3],S14,0xC1BDCEEE);
|
||||
a=ff(a,b,c,d,x[k+4],S11,0xF57C0FAF); d=ff(d,a,b,c,x[k+5],S12,0x4787C62A); c=ff(c,d,a,b,x[k+6],S13,0xA8304613); b=ff(b,c,d,a,x[k+7],S14,0xFD469501);
|
||||
a=ff(a,b,c,d,x[k+8],S11,0x698098D8); d=ff(d,a,b,c,x[k+9],S12,0x8B44F7AF); c=ff(c,d,a,b,x[k+10],S13,0xFFFF5BB1); b=ff(b,c,d,a,x[k+11],S14,0x895CD7BE);
|
||||
a=ff(a,b,c,d,x[k+12],S11,0x6B901122); d=ff(d,a,b,c,x[k+13],S12,0xFD987193); c=ff(c,d,a,b,x[k+14],S13,0xA679438E); b=ff(b,c,d,a,x[k+15],S14,0x49B40821);
|
||||
a=gg(a,b,c,d,x[k+1],S21,0xF61E2562); d=gg(d,a,b,c,x[k+6],S22,0xC040B340); c=gg(c,d,a,b,x[k+11],S23,0x265E5A51); b=gg(b,c,d,a,x[k+0],S24,0xE9B6C7AA);
|
||||
a=gg(a,b,c,d,x[k+5],S21,0xD62F105D); d=gg(d,a,b,c,x[k+10],S22,0x2441453); c=gg(c,d,a,b,x[k+15],S23,0xD8A1E681); b=gg(b,c,d,a,x[k+4],S24,0xE7D3FBC8);
|
||||
a=gg(a,b,c,d,x[k+9],S21,0x21E1CDE6); d=gg(d,a,b,c,x[k+14],S22,0xC33707D6); c=gg(c,d,a,b,x[k+3],S23,0xF4D50D87); b=gg(b,c,d,a,x[k+8],S24,0x455A14ED);
|
||||
a=gg(a,b,c,d,x[k+13],S21,0xA9E3E905); d=gg(d,a,b,c,x[k+2],S22,0xFCEFA3F8); c=gg(c,d,a,b,x[k+7],S23,0x676F02D9); b=gg(b,c,d,a,x[k+12],S24,0x8D2A4C8A);
|
||||
a=hh(a,b,c,d,x[k+5],S31,0xFFFA3942); d=hh(d,a,b,c,x[k+8],S32,0x8771F681); c=hh(c,d,a,b,x[k+11],S33,0x6D9D6122); b=hh(b,c,d,a,x[k+14],S34,0xFDE5380C);
|
||||
a=hh(a,b,c,d,x[k+1],S31,0xA4BEEA44); d=hh(d,a,b,c,x[k+4],S32,0x4BDECFA9); c=hh(c,d,a,b,x[k+7],S33,0xF6BB4B60); b=hh(b,c,d,a,x[k+10],S34,0xBEBFBC70);
|
||||
a=hh(a,b,c,d,x[k+13],S31,0x289B7EC6); d=hh(d,a,b,c,x[k+0],S32,0xEAA127FA); c=hh(c,d,a,b,x[k+3],S33,0xD4EF3085); b=hh(b,c,d,a,x[k+6],S34,0x4881D05);
|
||||
a=hh(a,b,c,d,x[k+9],S31,0xD9D4D039); d=hh(d,a,b,c,x[k+12],S32,0xE6DB99E5); c=hh(c,d,a,b,x[k+15],S33,0x1FA27CF8); b=hh(b,c,d,a,x[k+2],S34,0xC4AC5665);
|
||||
a=ii(a,b,c,d,x[k+0],S41,0xF4292244); d=ii(d,a,b,c,x[k+7],S42,0x432AFF97); c=ii(c,d,a,b,x[k+14],S43,0xAB9423A7); b=ii(b,c,d,a,x[k+5],S44,0xFC93A039);
|
||||
a=ii(a,b,c,d,x[k+12],S41,0x655B59C3); d=ii(d,a,b,c,x[k+3],S42,0x8F0CCC92); c=ii(c,d,a,b,x[k+10],S43,0xFFEFF47D); b=ii(b,c,d,a,x[k+1],S44,0x85845DD1);
|
||||
a=ii(a,b,c,d,x[k+8],S41,0x6FA87E4F); d=ii(d,a,b,c,x[k+15],S42,0xFE2CE6E0); c=ii(c,d,a,b,x[k+6],S43,0xA3014314); b=ii(b,c,d,a,x[k+13],S44,0x4E0811A1);
|
||||
a=ii(a,b,c,d,x[k+4],S41,0xF7537E82); d=ii(d,a,b,c,x[k+11],S42,0xBD3AF235); c=ii(c,d,a,b,x[k+2],S43,0x2AD7D2BB); b=ii(b,c,d,a,x[k+9],S44,0xEB86D391);
|
||||
a=addUnsigned(a,AA); b=addUnsigned(b,BB); c=addUnsigned(c,CC); d=addUnsigned(d,DD);
|
||||
}
|
||||
return (wordToHex(a)+wordToHex(b)+wordToHex(c)+wordToHex(d)).toLowerCase();
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const authHeader = req.headers.get('Authorization');
|
||||
if (!authHeader) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const supabase = 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 supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const { association_id } = await req.json();
|
||||
if (!association_id) {
|
||||
return new Response(JSON.stringify({ error: 'Missing association_id' }), {
|
||||
status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Load config
|
||||
const { data: config, error: cfgErr } = await admin
|
||||
.from('mailchimp_configs')
|
||||
.select('*')
|
||||
.eq('association_id', association_id)
|
||||
.maybeSingle();
|
||||
|
||||
if (cfgErr || !config) {
|
||||
return new Response(JSON.stringify({ error: 'Mailchimp not configured for this association' }), {
|
||||
status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (!config.audience_id) {
|
||||
return new Response(JSON.stringify({ error: 'No audience selected' }), {
|
||||
status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Load owners
|
||||
const { data: owners, error: ownErr } = await admin
|
||||
.from('owners')
|
||||
.select('first_name, last_name, email, property_address, electronic_consent')
|
||||
.eq('association_id', association_id)
|
||||
.eq('status', 'active')
|
||||
.not('email', 'is', null);
|
||||
|
||||
if (ownErr) {
|
||||
return new Response(JSON.stringify({ error: ownErr.message }), {
|
||||
status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const baseUrl = `https://${config.server_prefix}.api.mailchimp.com/3.0`;
|
||||
const authMc = `Basic ${btoa(`anystring:${config.api_key}`)}`;
|
||||
|
||||
// Use Mailchimp batch operations endpoint for efficient PUT (upsert) of members
|
||||
const operations: any[] = [];
|
||||
const validOwners = (owners || []).filter(o => o.email && o.email.includes('@'));
|
||||
|
||||
for (const o of validOwners) {
|
||||
const hash = await md5Lower(o.email);
|
||||
operations.push({
|
||||
method: 'PUT',
|
||||
path: `/lists/${config.audience_id}/members/${hash}`,
|
||||
body: JSON.stringify({
|
||||
email_address: o.email,
|
||||
status_if_new: 'subscribed',
|
||||
merge_fields: {
|
||||
FNAME: o.first_name || '',
|
||||
LNAME: o.last_name || '',
|
||||
ADDRESS: o.property_address || '',
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let lastError: string | null = null;
|
||||
|
||||
if (operations.length === 0) {
|
||||
await admin.from('mailchimp_configs').update({
|
||||
last_sync_at: new Date().toISOString(),
|
||||
last_sync_status: 'success',
|
||||
last_sync_count: 0,
|
||||
last_sync_error: null,
|
||||
}).eq('id', config.id);
|
||||
|
||||
return new Response(JSON.stringify({ success: true, total: 0, succeeded: 0, failed: 0 }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Send in chunks of 100 sequentially via individual PUT calls (simpler than batch endpoint)
|
||||
for (let i = 0; i < operations.length; i++) {
|
||||
const op = operations[i];
|
||||
try {
|
||||
const r = await fetch(`${baseUrl}${op.path}`, {
|
||||
method: op.method,
|
||||
headers: { Authorization: authMc, 'Content-Type': 'application/json' },
|
||||
body: op.body,
|
||||
});
|
||||
if (r.ok) {
|
||||
succeeded++;
|
||||
} else {
|
||||
failed++;
|
||||
const txt = await r.text();
|
||||
lastError = txt.slice(0, 300);
|
||||
}
|
||||
} catch (e) {
|
||||
failed++;
|
||||
lastError = (e as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
const status = failed === 0 ? 'success' : (succeeded === 0 ? 'failed' : 'partial');
|
||||
await admin.from('mailchimp_configs').update({
|
||||
last_sync_at: new Date().toISOString(),
|
||||
last_sync_status: status,
|
||||
last_sync_count: succeeded,
|
||||
last_sync_error: lastError,
|
||||
}).eq('id', config.id);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
total: operations.length,
|
||||
succeeded,
|
||||
failed,
|
||||
error: lastError,
|
||||
}), { headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
||||
} catch (err) {
|
||||
return new Response(JSON.stringify({ error: (err as Error).message }), {
|
||||
status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,723 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
// Tables in dependency order (parents first)
|
||||
const MIGRATION_TABLES = [
|
||||
// No FK dependencies
|
||||
"associations",
|
||||
"company_settings",
|
||||
"email_templates",
|
||||
"fee_schedule_subcategories",
|
||||
"fee_schedules",
|
||||
"notify_board_templates",
|
||||
"owner_notification_templates",
|
||||
"announcements",
|
||||
// Depends on associations
|
||||
"units",
|
||||
"association_faqs",
|
||||
"association_fee_rules",
|
||||
"bank_accounts",
|
||||
"bids_quotes",
|
||||
"blocked_dates",
|
||||
"board_members",
|
||||
"board_votes",
|
||||
"budgets",
|
||||
"calendar_events",
|
||||
"call_logs",
|
||||
"chart_of_accounts",
|
||||
"checklists",
|
||||
"checks",
|
||||
"client_requests",
|
||||
"documents",
|
||||
"email_routing_rules",
|
||||
"email_senders",
|
||||
"email_server_settings",
|
||||
"estoppels",
|
||||
"inspections",
|
||||
"invoices",
|
||||
"legal_matters",
|
||||
"owner_update_tags",
|
||||
"parking_records",
|
||||
"payables",
|
||||
"payment_plans",
|
||||
"projects",
|
||||
"shared_folders",
|
||||
"status_updates",
|
||||
"tasks",
|
||||
"vendors",
|
||||
"violations",
|
||||
"violation_responses",
|
||||
"work_orders",
|
||||
// Depends on associations + owners/units
|
||||
"owners",
|
||||
"owner_updates",
|
||||
"owner_ledger_entries",
|
||||
"collections",
|
||||
"admin_payments",
|
||||
"arc_applications",
|
||||
"arc_application_comments",
|
||||
"arc_application_votes",
|
||||
"bank_reconciliations",
|
||||
"bank_transactions",
|
||||
"bank_transfers",
|
||||
"bill_approvals",
|
||||
"bill_comments",
|
||||
"billable_expenses",
|
||||
"bills",
|
||||
"client_invoices",
|
||||
"client_invoice_items",
|
||||
"deposit_batches",
|
||||
"deposit_batch_items",
|
||||
"document_validation_proofs",
|
||||
"email_history",
|
||||
"entity_comments",
|
||||
"entity_votes",
|
||||
"homeowner_requests",
|
||||
"in_app_notifications",
|
||||
"journal_entries",
|
||||
"owner_notification_proofs",
|
||||
// Auth-related
|
||||
"profiles",
|
||||
"user_roles",
|
||||
"role_permissions",
|
||||
] as const;
|
||||
|
||||
type MappingRow = {
|
||||
mapping_type: "table" | "column" | "id_value";
|
||||
source_table: string | null;
|
||||
destination_table: string | null;
|
||||
source_field: string | null;
|
||||
destination_field: string | null;
|
||||
source_value: string | null;
|
||||
destination_value: string | null;
|
||||
};
|
||||
|
||||
type ColumnMapping = {
|
||||
sourceField: string;
|
||||
destinationField: string;
|
||||
};
|
||||
|
||||
type IdValueMapping = {
|
||||
sourceField: string;
|
||||
destinationField: string | null;
|
||||
sourceValue: string;
|
||||
destinationValue: string;
|
||||
};
|
||||
|
||||
function isMissingValue(value: unknown) {
|
||||
return value === null || value === undefined || value === "";
|
||||
}
|
||||
|
||||
function normalizeName(value: unknown) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function buildMappingIndexes(mappings: MappingRow[]) {
|
||||
const sourceTableByTargetTable = new Map<string, string>();
|
||||
const columnMappingsByTargetTable = new Map<string, ColumnMapping[]>();
|
||||
const idMappingsByTargetTable = new Map<string, IdValueMapping[]>();
|
||||
|
||||
for (const mapping of mappings) {
|
||||
if (mapping.mapping_type === "table" && mapping.source_table && mapping.destination_table) {
|
||||
sourceTableByTargetTable.set(mapping.destination_table, mapping.source_table);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
mapping.mapping_type === "column" &&
|
||||
mapping.source_field &&
|
||||
mapping.destination_field
|
||||
) {
|
||||
const tableKey = mapping.destination_table || mapping.source_table;
|
||||
if (!tableKey) continue;
|
||||
|
||||
const existing = columnMappingsByTargetTable.get(tableKey) || [];
|
||||
existing.push({
|
||||
sourceField: mapping.source_field,
|
||||
destinationField: mapping.destination_field,
|
||||
});
|
||||
columnMappingsByTargetTable.set(tableKey, existing);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
mapping.mapping_type === "id_value" &&
|
||||
mapping.source_field &&
|
||||
mapping.source_value &&
|
||||
mapping.destination_value
|
||||
) {
|
||||
const tableKey = mapping.destination_table || mapping.source_table;
|
||||
if (!tableKey) continue;
|
||||
|
||||
const existing = idMappingsByTargetTable.get(tableKey) || [];
|
||||
existing.push({
|
||||
sourceField: mapping.source_field,
|
||||
destinationField: mapping.destination_field,
|
||||
sourceValue: mapping.source_value,
|
||||
destinationValue: mapping.destination_value,
|
||||
});
|
||||
idMappingsByTargetTable.set(tableKey, existing);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sourceTableByTargetTable,
|
||||
columnMappingsByTargetTable,
|
||||
idMappingsByTargetTable,
|
||||
};
|
||||
}
|
||||
|
||||
function applyIdValueMappings(
|
||||
row: Record<string, unknown>,
|
||||
mappings: IdValueMapping[],
|
||||
): Record<string, unknown> {
|
||||
const transformed = { ...row };
|
||||
|
||||
for (const mapping of mappings) {
|
||||
const currentValue = transformed[mapping.sourceField];
|
||||
if (String(currentValue ?? "") !== mapping.sourceValue) continue;
|
||||
|
||||
const targetField = mapping.destinationField || mapping.sourceField;
|
||||
transformed[targetField] = mapping.destinationValue;
|
||||
}
|
||||
|
||||
return transformed;
|
||||
}
|
||||
|
||||
function applyColumnMappings(
|
||||
row: Record<string, unknown>,
|
||||
mappings: ColumnMapping[],
|
||||
): Record<string, unknown> {
|
||||
const transformed = { ...row };
|
||||
|
||||
for (const mapping of mappings) {
|
||||
if (!(mapping.sourceField in transformed)) continue;
|
||||
if (!(mapping.destinationField in transformed)) {
|
||||
transformed[mapping.destinationField] = transformed[mapping.sourceField];
|
||||
}
|
||||
delete transformed[mapping.sourceField];
|
||||
}
|
||||
|
||||
return transformed;
|
||||
}
|
||||
|
||||
function applyAssociationReferenceMappings(
|
||||
row: Record<string, unknown>,
|
||||
associationIdMap: Map<string, string>,
|
||||
): Record<string, unknown> {
|
||||
if (associationIdMap.size === 0) return row;
|
||||
|
||||
const transformed = { ...row };
|
||||
const mapValue = (value: unknown) => {
|
||||
if (typeof value === "string" && associationIdMap.has(value)) {
|
||||
return associationIdMap.get(value)!;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
transformed.id = mapValue(transformed.id);
|
||||
transformed.association_id = mapValue(transformed.association_id);
|
||||
transformed.client_id = mapValue(transformed.client_id);
|
||||
|
||||
if (Array.isArray(transformed.assigned_client_ids)) {
|
||||
transformed.assigned_client_ids = transformed.assigned_client_ids.map(mapValue);
|
||||
}
|
||||
|
||||
return transformed;
|
||||
}
|
||||
|
||||
function applyLegacyFallbacks(row: Record<string, unknown>): Record<string, unknown> {
|
||||
const transformed = { ...row };
|
||||
|
||||
if (isMissingValue(transformed.association_id)) {
|
||||
if (!isMissingValue(transformed.client_id)) {
|
||||
transformed.association_id = transformed.client_id;
|
||||
} else if (
|
||||
Array.isArray(transformed.assigned_client_ids) &&
|
||||
transformed.assigned_client_ids.length === 1
|
||||
) {
|
||||
transformed.association_id = transformed.assigned_client_ids[0];
|
||||
}
|
||||
}
|
||||
|
||||
return transformed;
|
||||
}
|
||||
|
||||
function stripUnknownColumns(
|
||||
row: Record<string, unknown>,
|
||||
validColumns: Set<string>,
|
||||
): Record<string, unknown> {
|
||||
const cleaned: Record<string, unknown> = {};
|
||||
|
||||
for (const key of Object.keys(row)) {
|
||||
if (validColumns.has(key)) {
|
||||
cleaned[key] = row[key];
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
async function getTargetColumns(targetClient: any, tableName: string) {
|
||||
try {
|
||||
const { data, error } = await targetClient.from(tableName).select("*").limit(1);
|
||||
if (error) {
|
||||
console.error(`[${tableName}] schema probe error:`, error.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (data && data.length > 0) {
|
||||
return new Set(Object.keys(data[0]));
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function buildAssociationIdMap(
|
||||
sourceClient: any,
|
||||
targetClient: any,
|
||||
sourceAssociationsTable: string,
|
||||
) {
|
||||
try {
|
||||
let sourceRows: Array<{ id: string; name: string | null }> = [];
|
||||
let offset = 0;
|
||||
const limit = 1000;
|
||||
let fetching = true;
|
||||
|
||||
while (fetching) {
|
||||
const { data, error } = await sourceClient
|
||||
.from(sourceAssociationsTable)
|
||||
.select("id, name")
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (error) {
|
||||
console.error("Failed to build association ID map from source:", error.message);
|
||||
return new Map<string, string>();
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
fetching = false;
|
||||
} else {
|
||||
sourceRows = sourceRows.concat(data as Array<{ id: string; name: string | null }>);
|
||||
if (data.length < limit) fetching = false;
|
||||
offset += limit;
|
||||
}
|
||||
}
|
||||
|
||||
const { data: targetRows, error: targetError } = await targetClient
|
||||
.from("associations")
|
||||
.select("id, name");
|
||||
|
||||
if (targetError) {
|
||||
console.error("Failed to build association ID map from target:", targetError.message);
|
||||
return new Map<string, string>();
|
||||
}
|
||||
|
||||
const targetIdByName = new Map<string, string>();
|
||||
for (const row of targetRows || []) {
|
||||
const key = normalizeName(row.name);
|
||||
if (key && !targetIdByName.has(key)) {
|
||||
targetIdByName.set(key, row.id);
|
||||
}
|
||||
}
|
||||
|
||||
const associationIdMap = new Map<string, string>();
|
||||
for (const row of sourceRows) {
|
||||
const key = normalizeName(row.name);
|
||||
const matchingTargetId = targetIdByName.get(key);
|
||||
if (row.id && matchingTargetId && row.id !== matchingTargetId) {
|
||||
associationIdMap.set(row.id, matchingTargetId);
|
||||
}
|
||||
}
|
||||
|
||||
return associationIdMap;
|
||||
} catch (error) {
|
||||
console.error("Failed to create association ID map:", error);
|
||||
return new Map<string, string>();
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertWithUnknownColumnRecovery(
|
||||
targetClient: any,
|
||||
tableName: string,
|
||||
rows: Record<string, unknown>[],
|
||||
) {
|
||||
let workingRows = rows.map((row) => ({ ...row }));
|
||||
const strippedColumns = new Set<string>();
|
||||
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const { error } = await targetClient.from(tableName).upsert(workingRows, { onConflict: "id" });
|
||||
|
||||
if (!error) {
|
||||
return {
|
||||
success: true,
|
||||
rows: workingRows,
|
||||
strippedColumns: [...strippedColumns],
|
||||
};
|
||||
}
|
||||
|
||||
const unknownColumn = error.message?.match(/Could not find the '([^']+)' column/)?.[1];
|
||||
if (!unknownColumn) {
|
||||
return {
|
||||
success: false,
|
||||
rows: workingRows,
|
||||
strippedColumns: [...strippedColumns],
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
strippedColumns.add(unknownColumn);
|
||||
workingRows = workingRows.map((row) => {
|
||||
const cleaned = { ...row };
|
||||
delete cleaned[unknownColumn];
|
||||
return cleaned;
|
||||
});
|
||||
console.log(`[${tableName}] stripping unknown column during upsert: ${unknownColumn}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
rows: workingRows,
|
||||
strippedColumns: [...strippedColumns],
|
||||
error: { message: "Exceeded unknown-column retry limit" },
|
||||
};
|
||||
}
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader) throw new Error("No authorization header");
|
||||
|
||||
const targetUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const targetServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const sourceUrl = Deno.env.get("SOURCE_SUPABASE_URL");
|
||||
const sourceServiceKey = Deno.env.get("SOURCE_SUPABASE_SERVICE_ROLE_KEY");
|
||||
|
||||
if (!sourceUrl || !sourceServiceKey) {
|
||||
throw new Error("Source Supabase credentials not configured");
|
||||
}
|
||||
|
||||
const targetClient = createClient(targetUrl, targetServiceKey);
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
|
||||
const payloadBase64 = token.split(".")[1];
|
||||
if (!payloadBase64) throw new Error("Invalid token");
|
||||
const payload = JSON.parse(atob(payloadBase64));
|
||||
const userId = payload.sub;
|
||||
if (!userId) throw new Error("Unauthorized");
|
||||
|
||||
const { data: roleData } = await targetClient
|
||||
.from("user_roles")
|
||||
.select("role")
|
||||
.eq("user_id", userId)
|
||||
.eq("role", "admin")
|
||||
.single();
|
||||
if (!roleData) throw new Error("Admin access required");
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const action = body.action || "migrate_tables";
|
||||
const selectedTables = body.tables || MIGRATION_TABLES;
|
||||
|
||||
const { data: mappingsData, error: mappingsError } = await targetClient
|
||||
.from("migration_field_mappings")
|
||||
.select(
|
||||
"mapping_type, source_table, destination_table, source_field, destination_field, source_value, destination_value",
|
||||
)
|
||||
.eq("is_active", true);
|
||||
|
||||
if (mappingsError) {
|
||||
console.error("Failed to load migration mappings:", mappingsError.message);
|
||||
}
|
||||
|
||||
const {
|
||||
sourceTableByTargetTable,
|
||||
columnMappingsByTargetTable,
|
||||
idMappingsByTargetTable,
|
||||
} = buildMappingIndexes((mappingsData || []) as MappingRow[]);
|
||||
|
||||
const resolveSourceTable = (targetTable: string) => {
|
||||
return sourceTableByTargetTable.get(targetTable) || targetTable;
|
||||
};
|
||||
|
||||
const sourceClient = createClient(sourceUrl, sourceServiceKey);
|
||||
const associationIdMap = await buildAssociationIdMap(
|
||||
sourceClient,
|
||||
targetClient,
|
||||
resolveSourceTable("associations"),
|
||||
);
|
||||
|
||||
if (action === "list_source_tables") {
|
||||
const tableCounts: Record<string, number> = {};
|
||||
|
||||
for (const targetTable of MIGRATION_TABLES) {
|
||||
const sourceTable = resolveSourceTable(targetTable);
|
||||
|
||||
try {
|
||||
const { count } = await sourceClient
|
||||
.from(sourceTable)
|
||||
.select("*", { count: "exact", head: true });
|
||||
tableCounts[targetTable] = count ?? 0;
|
||||
} catch {
|
||||
tableCounts[targetTable] = -1;
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ success: true, tables: tableCounts }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "import_users_with_passwords") {
|
||||
const usersList = body.users || [];
|
||||
if (!Array.isArray(usersList) || usersList.length === 0) {
|
||||
throw new Error("No users provided. Send an array of {email, encrypted_password, ...} objects.");
|
||||
}
|
||||
|
||||
const results: { created: number; skipped: number; errors: string[] } = {
|
||||
created: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
for (const userData of usersList) {
|
||||
try {
|
||||
if (!userData.email) {
|
||||
results.errors.push("Row missing email field");
|
||||
continue;
|
||||
}
|
||||
|
||||
const createPayload: Record<string, unknown> = {
|
||||
email: userData.email,
|
||||
email_confirm: true,
|
||||
user_metadata: userData.user_metadata || {},
|
||||
};
|
||||
|
||||
if (userData.encrypted_password) {
|
||||
createPayload.password_hash = userData.encrypted_password;
|
||||
} else if (userData.password) {
|
||||
createPayload.password = userData.password;
|
||||
} else {
|
||||
createPayload.password = `Temp${crypto.randomUUID().slice(0, 8)}!`;
|
||||
}
|
||||
|
||||
const { error: createErr } = await targetClient.auth.admin.createUser(createPayload);
|
||||
|
||||
if (createErr) {
|
||||
if (createErr.message?.includes("already been registered")) {
|
||||
results.skipped++;
|
||||
} else {
|
||||
results.errors.push(`${userData.email}: ${createErr.message}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
results.created++;
|
||||
} catch (e) {
|
||||
results.errors.push(`${userData.email || "unknown"}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ success: true, users: results }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "migrate_users") {
|
||||
const results: { created: number; skipped: number; errors: string[] } = {
|
||||
created: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const {
|
||||
data: { users },
|
||||
error,
|
||||
} = await sourceClient.auth.admin.listUsers({ page, perPage });
|
||||
|
||||
if (error) {
|
||||
results.errors.push(`Failed to list users page ${page}: ${(error as Error).message}`);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!users || users.length === 0) {
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
for (const srcUser of users) {
|
||||
try {
|
||||
const tempPassword = `Temp${crypto.randomUUID().slice(0, 8)}!`;
|
||||
const { error: createErr } = await targetClient.auth.admin.createUser({
|
||||
email: srcUser.email!,
|
||||
password: tempPassword,
|
||||
email_confirm: true,
|
||||
user_metadata: srcUser.user_metadata || {},
|
||||
});
|
||||
|
||||
if (createErr) {
|
||||
if (createErr.message?.includes("already been registered")) {
|
||||
results.skipped++;
|
||||
} else {
|
||||
results.errors.push(`${srcUser.email}: ${createErr.message}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
results.created++;
|
||||
} catch (e) {
|
||||
results.errors.push(`${srcUser.email}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (users.length < perPage) hasMore = false;
|
||||
page++;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ success: true, users: results }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "migrate_tables") {
|
||||
const results: Record<string, { inserted: number; skipped: number; error?: string }> = {};
|
||||
|
||||
for (const requestedTable of selectedTables) {
|
||||
if (!MIGRATION_TABLES.includes(requestedTable)) continue;
|
||||
|
||||
const targetTable = requestedTable;
|
||||
const sourceTable = resolveSourceTable(targetTable);
|
||||
const columnMappings = columnMappingsByTargetTable.get(targetTable) || [];
|
||||
const idMappings = idMappingsByTargetTable.get(targetTable) || [];
|
||||
|
||||
try {
|
||||
let allRows: Record<string, unknown>[] = [];
|
||||
let offset = 0;
|
||||
const limit = 1000;
|
||||
let fetching = true;
|
||||
|
||||
while (fetching) {
|
||||
const { data, error } = await sourceClient
|
||||
.from(sourceTable)
|
||||
.select("*")
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (error) {
|
||||
console.error(`[${targetTable}] fetch error from ${sourceTable} at offset ${offset}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
fetching = false;
|
||||
} else {
|
||||
allRows = allRows.concat(data as Record<string, unknown>[]);
|
||||
if (data.length < limit) fetching = false;
|
||||
offset += limit;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[${targetTable}] fetched ${allRows.length} rows from source table ${sourceTable}`);
|
||||
|
||||
if (allRows.length === 0) {
|
||||
results[targetTable] = { inserted: 0, skipped: 0 };
|
||||
continue;
|
||||
}
|
||||
|
||||
let transformedRows = allRows.map((row) => {
|
||||
let transformed = applyIdValueMappings(row, idMappings);
|
||||
transformed = applyAssociationReferenceMappings(transformed, associationIdMap);
|
||||
transformed = applyColumnMappings(transformed, columnMappings);
|
||||
transformed = applyLegacyFallbacks(transformed);
|
||||
transformed = applyAssociationReferenceMappings(transformed, associationIdMap);
|
||||
return transformed;
|
||||
});
|
||||
|
||||
const validColumns = await getTargetColumns(targetClient, targetTable);
|
||||
if (validColumns) {
|
||||
const strippedColumns = new Set<string>();
|
||||
transformedRows = transformedRows.map((row) => {
|
||||
for (const key of Object.keys(row)) {
|
||||
if (!validColumns.has(key)) strippedColumns.add(key);
|
||||
}
|
||||
return stripUnknownColumns(row, validColumns);
|
||||
});
|
||||
|
||||
if (strippedColumns.size > 0) {
|
||||
console.log(`[${targetTable}] stripped columns not in target: ${[...strippedColumns].join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
const batchSize = 100;
|
||||
|
||||
for (let i = 0; i < transformedRows.length; i += batchSize) {
|
||||
const batch = transformedRows.slice(i, i + batchSize);
|
||||
const batchResult = await upsertWithUnknownColumnRecovery(targetClient, targetTable, batch);
|
||||
|
||||
if (!batchResult.success) {
|
||||
console.error(
|
||||
`[${targetTable}] batch upsert error at offset ${i}:`,
|
||||
batchResult.error?.message,
|
||||
batchResult.error?.details,
|
||||
batchResult.error?.hint,
|
||||
);
|
||||
|
||||
for (const row of batchResult.rows) {
|
||||
const singleResult = await upsertWithUnknownColumnRecovery(targetClient, targetTable, [row]);
|
||||
if (!singleResult.success) {
|
||||
console.error(`[${targetTable}] single row error:`, singleResult.error?.message);
|
||||
skipped++;
|
||||
} else {
|
||||
inserted++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (batchResult.strippedColumns.length > 0) {
|
||||
console.log(`[${targetTable}] stripped during upsert: ${batchResult.strippedColumns.join(", ")}`);
|
||||
}
|
||||
inserted += batchResult.rows.length;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[${targetTable}] result: ${inserted} inserted/updated, ${skipped} skipped`);
|
||||
results[targetTable] = { inserted, skipped };
|
||||
} catch (e) {
|
||||
console.error(`[${targetTable}] fatal error:`, (e as Error).message);
|
||||
results[targetTable] = { inserted: 0, skipped: 0, error: (e as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ success: true, results }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Unknown action: ${action}`);
|
||||
} catch (error) {
|
||||
console.error("Migration error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: (error as Error).message }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.45.0'
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
}
|
||||
|
||||
const formatEST = (iso?: string | null) => {
|
||||
if (!iso) return ''
|
||||
try {
|
||||
const d = new Date(iso)
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'America/New_York',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
}).format(d) + ' ET'
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
|
||||
|
||||
try {
|
||||
const { announcement_id } = await req.json()
|
||||
if (!announcement_id) {
|
||||
return new Response(JSON.stringify({ error: 'announcement_id required' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
|
||||
)
|
||||
|
||||
const { data: ann, error: annErr } = await supabase
|
||||
.from('announcements')
|
||||
.select('*')
|
||||
.eq('id', announcement_id)
|
||||
.single()
|
||||
|
||||
if (annErr || !ann) {
|
||||
return new Response(JSON.stringify({ error: 'announcement not found' }), {
|
||||
status: 404,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
// Skip if expired or not active
|
||||
if (ann.status !== 'active') {
|
||||
return new Response(JSON.stringify({ ok: true, skipped: 'inactive' }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
if (ann.expires_at && new Date(ann.expires_at) <= new Date()) {
|
||||
return new Response(JSON.stringify({ ok: true, skipped: 'expired' }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
// public_only is community page only — do not email
|
||||
if (ann.visibility === 'public_only') {
|
||||
return new Response(JSON.stringify({ ok: true, skipped: 'public_only' }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
// Determine recipient user_ids based on visibility
|
||||
const recipientUserIds = new Set<string>()
|
||||
|
||||
// Always include staff (admin + manager)
|
||||
const { data: staffRows } = await supabase
|
||||
.from('user_roles')
|
||||
.select('user_id')
|
||||
.in('role', ['admin', 'manager'])
|
||||
for (const r of staffRows || []) if (r.user_id) recipientUserIds.add(r.user_id)
|
||||
|
||||
// Board members — scope to association if set, else all
|
||||
let boardQuery = supabase.from('board_members').select('user_id, association_id')
|
||||
if (ann.association_id) boardQuery = boardQuery.eq('association_id', ann.association_id)
|
||||
const { data: boardRows } = await boardQuery
|
||||
for (const r of boardRows || []) if (r.user_id) recipientUserIds.add(r.user_id)
|
||||
|
||||
// Homeowners (only when visibility is 'all' or 'public')
|
||||
const includeHomeowners = ann.visibility === 'all' || ann.visibility === 'public'
|
||||
let homeownerEmails: { email: string; name: string }[] = []
|
||||
if (includeHomeowners) {
|
||||
let ownerQuery = supabase
|
||||
.from('owners')
|
||||
.select('user_id, email, first_name, last_name, status')
|
||||
.eq('status', 'active')
|
||||
if (ann.association_id) ownerQuery = ownerQuery.eq('association_id', ann.association_id)
|
||||
const { data: ownerRows } = await ownerQuery
|
||||
for (const o of ownerRows || []) {
|
||||
if (o.user_id) recipientUserIds.add(o.user_id)
|
||||
else if (o.email) {
|
||||
homeownerEmails.push({
|
||||
email: String(o.email).trim(),
|
||||
name: [o.first_name, o.last_name].filter(Boolean).join(' '),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look up emails from auth.users for collected user_ids
|
||||
const userIdList = Array.from(recipientUserIds)
|
||||
const recipients: { email: string; name: string; id: string }[] = []
|
||||
|
||||
if (userIdList.length > 0) {
|
||||
// paginate auth users
|
||||
let page = 1
|
||||
while (true) {
|
||||
const { data: authData, error: authErr } = await supabase.auth.admin.listUsers({ page, perPage: 1000 })
|
||||
if (authErr || !authData?.users?.length) break
|
||||
for (const u of authData.users) {
|
||||
if (recipientUserIds.has(u.id) && u.email) {
|
||||
const meta: any = u.user_metadata || {}
|
||||
recipients.push({
|
||||
email: u.email,
|
||||
name: meta.full_name || meta.first_name || '',
|
||||
id: u.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (authData.users.length < 1000) break
|
||||
page++
|
||||
if (page > 10) break
|
||||
}
|
||||
}
|
||||
|
||||
// Add homeowner emails (no auth account)
|
||||
for (const h of homeownerEmails) {
|
||||
recipients.push({ email: h.email, name: h.name, id: `owner-${h.email}` })
|
||||
}
|
||||
|
||||
// Dedupe by lowercased email
|
||||
const seen = new Set<string>()
|
||||
const uniqueRecipients = recipients.filter(r => {
|
||||
const key = r.email.toLowerCase()
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
|
||||
// Look up association name for context
|
||||
let associationName = ''
|
||||
if (ann.association_id) {
|
||||
const { data: assoc } = await supabase
|
||||
.from('associations')
|
||||
.select('name')
|
||||
.eq('id', ann.association_id)
|
||||
.single()
|
||||
associationName = assoc?.name || ''
|
||||
}
|
||||
|
||||
const postedAt = formatEST(ann.created_at)
|
||||
const expiresAt = formatEST(ann.expires_at)
|
||||
|
||||
let sent = 0
|
||||
let failed = 0
|
||||
for (const r of uniqueRecipients) {
|
||||
try {
|
||||
const { error: emailError } = await supabase.functions.invoke('send-transactional-email', {
|
||||
body: {
|
||||
templateName: 'announcement-broadcast',
|
||||
recipientEmail: r.email,
|
||||
idempotencyKey: `announcement-${ann.id}-${r.id}`,
|
||||
templateData: {
|
||||
recipientName: r.name || '',
|
||||
title: ann.title,
|
||||
contentHtml: ann.content || '',
|
||||
postedAt,
|
||||
expiresAt,
|
||||
associationName,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (emailError) {
|
||||
failed++
|
||||
console.error(`Email to ${r.email} failed:`, emailError)
|
||||
} else {
|
||||
sent++
|
||||
}
|
||||
} catch (e) {
|
||||
failed++
|
||||
console.error(`Email exception for ${r.email}:`, e)
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, recipients: uniqueRecipients.length, sent, failed }),
|
||||
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } },
|
||||
)
|
||||
} catch (err: any) {
|
||||
console.error('notify-announcement error:', err)
|
||||
return new Response(JSON.stringify({ error: err.message || String(err) }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.45.0'
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
}
|
||||
|
||||
const SOURCE_TYPE_LABELS: Record<string, string> = {
|
||||
public_form: 'Public form submission',
|
||||
client_request: 'Client request',
|
||||
homeowner_ticket: 'Homeowner ticket',
|
||||
violation_response: 'Violation response',
|
||||
registration_request: 'Registration request',
|
||||
}
|
||||
|
||||
const SOURCE_TYPE_LINKS: Record<string, string> = {
|
||||
public_form: '/dashboard/form-inbox',
|
||||
client_request: '/dashboard/client-requests',
|
||||
homeowner_ticket: '/dashboard/form-inbox',
|
||||
violation_response: '/dashboard/form-inbox',
|
||||
registration_request: '/dashboard/form-inbox',
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
|
||||
|
||||
try {
|
||||
const { inbox_id } = await req.json()
|
||||
if (!inbox_id) {
|
||||
return new Response(JSON.stringify({ error: 'inbox_id required' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
|
||||
)
|
||||
|
||||
// Fetch the inbox entry
|
||||
const { data: inbox, error: inboxError } = await supabase
|
||||
.from('form_inbox')
|
||||
.select('*')
|
||||
.eq('id', inbox_id)
|
||||
.single()
|
||||
|
||||
if (inboxError || !inbox) {
|
||||
console.error('Inbox lookup failed:', inboxError)
|
||||
return new Response(JSON.stringify({ error: 'inbox entry not found' }), {
|
||||
status: 404,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const sourceLabel = SOURCE_TYPE_LABELS[inbox.source_type] || 'Form submission'
|
||||
const link = SOURCE_TYPE_LINKS[inbox.source_type] || '/dashboard/form-inbox'
|
||||
|
||||
// Get admin + manager user IDs
|
||||
const { data: roleRows, error: roleError } = await supabase
|
||||
.from('user_roles')
|
||||
.select('user_id')
|
||||
.in('role', ['admin', 'manager'])
|
||||
|
||||
if (roleError) {
|
||||
console.error('Role lookup failed:', roleError)
|
||||
return new Response(JSON.stringify({ error: 'failed to fetch staff' }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const userIds = Array.from(new Set((roleRows || []).map((r) => r.user_id).filter(Boolean)))
|
||||
if (userIds.length === 0) {
|
||||
return new Response(JSON.stringify({ ok: true, notified: 0 }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
// Insert in-app notifications for every staff user
|
||||
const notificationRows = userIds.map((uid) => ({
|
||||
user_id: uid,
|
||||
type: 'form_submission_received',
|
||||
title: `New ${sourceLabel.toLowerCase()}`,
|
||||
message: `${inbox.submitter_name || 'Someone'} submitted: ${inbox.title}`,
|
||||
related_item_id: inbox.id,
|
||||
related_item_type: 'form_inbox',
|
||||
link,
|
||||
}))
|
||||
const { error: notifError } = await supabase
|
||||
.from('in_app_notifications')
|
||||
.insert(notificationRows)
|
||||
if (notifError) console.error('Notification insert error:', notifError)
|
||||
|
||||
// Look up staff emails from auth.users
|
||||
const { data: authData, error: authError } = await supabase.auth.admin.listUsers({
|
||||
page: 1,
|
||||
perPage: 1000,
|
||||
})
|
||||
if (authError) {
|
||||
console.error('Auth list failed:', authError)
|
||||
}
|
||||
|
||||
const staffEmails = (authData?.users || [])
|
||||
.filter((u) => userIds.includes(u.id) && u.email)
|
||||
.map((u) => ({ id: u.id, email: u.email as string }))
|
||||
|
||||
const baseUrl = Deno.env.get('SUPABASE_URL') || ''
|
||||
const projectId = baseUrl.replace('https://', '').split('.')[0]
|
||||
const fullLink = `https://avria.cloud${link}`
|
||||
|
||||
let emailsSent = 0
|
||||
let emailsFailed = 0
|
||||
|
||||
for (const staff of staffEmails) {
|
||||
try {
|
||||
const { error: emailError } = await supabase.functions.invoke(
|
||||
'send-transactional-email',
|
||||
{
|
||||
body: {
|
||||
templateName: 'ticket-submitted',
|
||||
recipientEmail: staff.email,
|
||||
idempotencyKey: `form-inbox-${inbox.id}-${staff.id}`,
|
||||
templateData: {
|
||||
recipientName: '',
|
||||
homeownerName: inbox.submitter_name || 'Anonymous',
|
||||
ticketTitle: inbox.title,
|
||||
category: sourceLabel,
|
||||
priority: '',
|
||||
summary: inbox.summary || '',
|
||||
link: fullLink,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
if (emailError) {
|
||||
emailsFailed++
|
||||
console.error(`Email to ${staff.email} failed:`, emailError)
|
||||
} else {
|
||||
emailsSent++
|
||||
}
|
||||
} catch (e) {
|
||||
emailsFailed++
|
||||
console.error(`Email exception for ${staff.email}:`, e)
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
notified: userIds.length,
|
||||
emails_sent: emailsSent,
|
||||
emails_failed: emailsFailed,
|
||||
}),
|
||||
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } },
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('notify-staff-new-form error:', e)
|
||||
return new Response(JSON.stringify({ error: String(e) }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,196 @@
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
function sanitizeJsonCandidate(value: string) {
|
||||
return value
|
||||
.replace(/^```(?:json)?\s*/i, "")
|
||||
.replace(/```$/i, "")
|
||||
.replace(/[\u0000-\u001F\u007F-\u009F]/g, (char) =>
|
||||
char === "\n" || char === "\r" || char === "\t" ? char : " ",
|
||||
)
|
||||
.replace(/,\s*([}\]])/g, "$1")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractJsonObject(value: string) {
|
||||
const fenced = value.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1];
|
||||
const source = fenced || value;
|
||||
const start = source.indexOf("{");
|
||||
if (start === -1) return sanitizeJsonCandidate(source);
|
||||
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let i = start; i < source.length; i++) {
|
||||
const char = source[i];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (char === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
if (inString) continue;
|
||||
if (char === "{") depth++;
|
||||
if (char === "}") depth--;
|
||||
if (depth === 0) return sanitizeJsonCandidate(source.slice(start, i + 1));
|
||||
}
|
||||
|
||||
throw new Error("AI response was truncated before the JSON object closed");
|
||||
}
|
||||
|
||||
function parseAiJson(content: string) {
|
||||
const candidates = [content.trim(), extractJsonObject(content)];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
return JSON.parse(sanitizeJsonCandidate(candidate));
|
||||
} catch {
|
||||
// try the next extraction strategy
|
||||
}
|
||||
}
|
||||
throw new Error("Failed to parse AI response as valid JSON");
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const { pdf_base64, filename } = await req.json();
|
||||
|
||||
if (!pdf_base64) {
|
||||
return new Response(JSON.stringify({ error: "No PDF data provided" }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY");
|
||||
if (!LOVABLE_API_KEY) {
|
||||
return new Response(JSON.stringify({ error: "AI API key not configured" }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const prompt = `You are a meticulous invoice data extraction AI. Analyze the provided PDF invoice carefully — examine EVERY page and EVERY line item, including continuation pages, sub-totals, and tables. Do not skip or summarize line items; capture each one individually exactly as it appears.
|
||||
|
||||
CRITICAL RULES:
|
||||
- Read the entire document end-to-end before responding.
|
||||
- Extract EVERY line item separately, even if there are 50+ rows. Do not collapse, group, or omit any.
|
||||
- Preserve the exact wording from the invoice for descriptions and names.
|
||||
- For dates, convert to YYYY-MM-DD. If only month/year is shown, use the 1st of the month.
|
||||
- For monetary values, use numbers only (no currency symbols, no commas). Negative amounts (credits/discounts) should be negative numbers.
|
||||
- If a field truly cannot be determined from the document, use null for strings and 0 for numbers — do NOT guess.
|
||||
- The sum of line item amounts should reconcile with the subtotal; double-check before returning.
|
||||
- Return a complete, syntactically valid JSON object with no markdown fences, comments, trailing commas, or extra text.
|
||||
- The sum of line item amounts should reconcile with the subtotal; double-check before returning.
|
||||
|
||||
Return ONLY valid JSON (no markdown, no code blocks, no commentary) with this exact structure:
|
||||
{
|
||||
"vendor_name": "string",
|
||||
"vendor_address": "string or null",
|
||||
"vendor_phone": "string or null",
|
||||
"client_name": "string or null",
|
||||
"client_address": "string or null",
|
||||
"invoice_number": "string",
|
||||
"invoice_date": "YYYY-MM-DD",
|
||||
"due_date": "YYYY-MM-DD or null",
|
||||
"service_period": "string or null",
|
||||
"subtotal": number,
|
||||
"tax": number,
|
||||
"other_charges": number,
|
||||
"total_amount": number,
|
||||
"currency": "USD",
|
||||
"payment_terms": "string or null",
|
||||
"notes": "string or null",
|
||||
"line_items": [
|
||||
{
|
||||
"line_number": number,
|
||||
"description": "string (full description as printed)",
|
||||
"name": "string (short item name/SKU if present, else first words of description)",
|
||||
"date": "YYYY-MM-DD or null",
|
||||
"quantity": number or null,
|
||||
"unit_price": number or null,
|
||||
"amount": number,
|
||||
"category": "string or null",
|
||||
"notes": "string or null"
|
||||
}
|
||||
]
|
||||
}`;
|
||||
|
||||
const response = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${LOVABLE_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "google/gemini-2.5-pro",
|
||||
messages: [
|
||||
{ role: "system", content: prompt },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Extract ALL invoice data from this PDF file named "${filename || "invoice.pdf"}". Examine every page and capture every line item — do not skip or summarize any rows. Return only the JSON object.`,
|
||||
},
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:application/pdf;base64,${pdf_base64}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
temperature: 0,
|
||||
max_tokens: 32000,
|
||||
response_format: { type: "json_object" },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
console.error("AI Gateway error:", errText);
|
||||
return new Response(JSON.stringify({ error: `AI processing failed: ${response.status}` }), {
|
||||
status: 502,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const aiResult = await response.json();
|
||||
const content = aiResult.choices?.[0]?.message?.content || "";
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseAiJson(typeof content === "string" ? content : JSON.stringify(content));
|
||||
} catch (parseError) {
|
||||
console.error("Failed to parse AI response:", content);
|
||||
return new Response(JSON.stringify({ error: parseError.message || "Failed to parse AI response" }), {
|
||||
status: 422,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ data: parsed }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("parse-invoice error:", err);
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.99.1";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
// Compute next run date from a base date and frequency, ensuring result > today.
|
||||
function computeNextRunDate(baseISO: string, frequency: string, today: Date): string {
|
||||
const d = new Date(baseISO + "T12:00:00");
|
||||
const addByFreq = (date: Date) => {
|
||||
switch (frequency) {
|
||||
case "monthly": date.setMonth(date.getMonth() + 1); break;
|
||||
case "quarterly": date.setMonth(date.getMonth() + 3); break;
|
||||
case "semi-annual": date.setMonth(date.getMonth() + 6); break;
|
||||
case "annual": date.setFullYear(date.getFullYear() + 1); break;
|
||||
default: date.setMonth(date.getMonth() + 1);
|
||||
}
|
||||
};
|
||||
// Advance until strictly after today
|
||||
while (d <= today) addByFreq(d);
|
||||
return d.toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, serviceKey);
|
||||
|
||||
const today = new Date();
|
||||
const todayISO = today.toISOString().split("T")[0];
|
||||
|
||||
// Find all rules due to run
|
||||
const { data: dueRules, error: rulesErr } = await supabase
|
||||
.from("association_fee_rules")
|
||||
.select("*")
|
||||
.eq("auto_post_enabled", true)
|
||||
.lte("next_run_date", todayISO);
|
||||
|
||||
if (rulesErr) throw rulesErr;
|
||||
if (!dueRules || dueRules.length === 0) {
|
||||
return new Response(JSON.stringify({ processed: 0, posted: 0 }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
let totalPosted = 0;
|
||||
const results: any[] = [];
|
||||
|
||||
for (const rule of dueRules) {
|
||||
const associationId = rule.association_id;
|
||||
|
||||
// Load units + active owners for this association
|
||||
const [{ data: units }, { data: owners }] = await Promise.all([
|
||||
supabase
|
||||
.from("units")
|
||||
.select("id, monthly_assessment, assessment_amount_one, assessment_account_id")
|
||||
.eq("association_id", associationId),
|
||||
supabase
|
||||
.from("owners")
|
||||
.select("id, unit_id")
|
||||
.eq("association_id", associationId)
|
||||
.eq("status", "active"),
|
||||
]);
|
||||
|
||||
const ownerByUnit = new Map<string, string>();
|
||||
(owners || []).forEach((o: any) => { if (o.unit_id) ownerByUnit.set(o.unit_id, o.id); });
|
||||
|
||||
const entries = (units || []).flatMap((u: any) => {
|
||||
const ownerId = ownerByUnit.get(u.id);
|
||||
if (!ownerId) return [];
|
||||
const amount = u.assessment_amount_one ?? u.monthly_assessment ?? rule.default_assessment_amount ?? 0;
|
||||
if (!amount || amount <= 0) return [];
|
||||
return [{
|
||||
association_id: associationId,
|
||||
owner_id: ownerId,
|
||||
unit_id: u.id,
|
||||
date: todayISO,
|
||||
credit: 0,
|
||||
debit: amount,
|
||||
transaction_type: "assessment",
|
||||
description: "Assessment (auto-posted)",
|
||||
}];
|
||||
});
|
||||
|
||||
let posted = 0;
|
||||
if (entries.length) {
|
||||
const { error: insErr } = await supabase.from("owner_ledger_entries").insert(entries);
|
||||
if (insErr) {
|
||||
console.error(`Failed posting for association ${associationId}:`, insErr);
|
||||
results.push({ associationId, error: insErr.message });
|
||||
continue;
|
||||
}
|
||||
posted = entries.length;
|
||||
totalPosted += posted;
|
||||
}
|
||||
|
||||
// Advance the cycle
|
||||
const newNext = computeNextRunDate(rule.next_run_date || todayISO, rule.assessment_frequency || "monthly", today);
|
||||
await supabase
|
||||
.from("association_fee_rules")
|
||||
.update({ last_run_date: todayISO, next_run_date: newNext })
|
||||
.eq("id", rule.id);
|
||||
|
||||
results.push({ associationId, posted, next_run_date: newNext });
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ processed: dueRules.length, posted: totalPosted, results }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("post-recurring-assessments error:", err);
|
||||
return new Response(JSON.stringify({ error: (err as Error).message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,344 @@
|
||||
// Auto-applies late fees and interest based on association_fee_rules.
|
||||
// Idempotent: skips owners that already received the same fee type within the current period.
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.99.1";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
type FeeExclusion = {
|
||||
fee_type: "late_fee" | "interest";
|
||||
mode: "waive" | "override_amount" | "override_percent";
|
||||
override_amount: number | null;
|
||||
override_percent: number | null;
|
||||
};
|
||||
|
||||
function periodKey(today: Date, schedule: string): string {
|
||||
const y = today.getUTCFullYear();
|
||||
const m = today.getUTCMonth();
|
||||
if (schedule === "quarterly") return `${y}-Q${Math.floor(m / 3) + 1}`;
|
||||
if (schedule === "annual") return `${y}`;
|
||||
return `${y}-${String(m + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function periodWindow(today: Date, schedule: string): { start: string; end: string } {
|
||||
const y = today.getUTCFullYear();
|
||||
const m = today.getUTCMonth();
|
||||
let startMonth = m;
|
||||
let monthsInPeriod = 1;
|
||||
if (schedule === "quarterly") {
|
||||
startMonth = Math.floor(m / 3) * 3;
|
||||
monthsInPeriod = 3;
|
||||
} else if (schedule === "annual") {
|
||||
startMonth = 0;
|
||||
monthsInPeriod = 12;
|
||||
}
|
||||
const start = new Date(Date.UTC(y, startMonth, 1)).toISOString().slice(0, 10);
|
||||
const end = new Date(Date.UTC(y, startMonth + monthsInPeriod, 0)).toISOString().slice(0, 10);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function resolveAmount(requested: number, exclusion: FeeExclusion | null, balance: number): number {
|
||||
if (!(requested > 0)) return 0;
|
||||
if (!exclusion) return requested;
|
||||
if (exclusion.mode === "waive") return 0;
|
||||
if (exclusion.mode === "override_amount") {
|
||||
const ov = Number(exclusion.override_amount || 0);
|
||||
return ov > 0 ? Number(ov.toFixed(2)) : 0;
|
||||
}
|
||||
if (exclusion.mode === "override_percent" && exclusion.override_percent != null) {
|
||||
const pct = Number(exclusion.override_percent); // stored as fraction (e.g. 0.015)
|
||||
if (pct <= 0 || balance <= 0) return 0;
|
||||
return Number((balance * pct).toFixed(2));
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
|
||||
|
||||
try {
|
||||
const supabase = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
|
||||
);
|
||||
|
||||
const url = new URL(req.url);
|
||||
const dryRun = url.searchParams.get("dry_run") === "1";
|
||||
const forceAssoc = url.searchParams.get("association_id");
|
||||
const force = url.searchParams.get("force") === "1";
|
||||
|
||||
const today = new Date();
|
||||
const todayISO = today.toISOString().slice(0, 10);
|
||||
const todayDay = today.getUTCDate();
|
||||
|
||||
// 1. Load all enabled rules
|
||||
let rulesQuery = supabase
|
||||
.from("association_fee_rules")
|
||||
.select("*")
|
||||
.eq("auto_apply_enabled", true);
|
||||
if (forceAssoc) rulesQuery = rulesQuery.eq("association_id", forceAssoc);
|
||||
const { data: rules, error: rulesErr } = await rulesQuery;
|
||||
if (rulesErr) throw rulesErr;
|
||||
|
||||
const eligibleRules = (rules || []).filter((r: any) => {
|
||||
if (!r.late_fee_enabled && !r.interest_enabled) return false;
|
||||
if (force) return true;
|
||||
// Only run when today >= configured day in current period
|
||||
const day = Number(r.auto_apply_day || 1);
|
||||
if (todayDay < day) return false;
|
||||
// Only run for current month boundaries based on schedule
|
||||
const sched = r.auto_apply_schedule || "monthly";
|
||||
if (sched === "quarterly" && today.getUTCMonth() % 3 !== 0) return false;
|
||||
if (sched === "annual" && today.getUTCMonth() !== 0) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const summary: any[] = [];
|
||||
let totalLateFees = 0;
|
||||
let totalInterest = 0;
|
||||
|
||||
for (const rule of eligibleRules) {
|
||||
const associationId = rule.association_id;
|
||||
const sched = rule.auto_apply_schedule || "monthly";
|
||||
const { start: periodStart, end: periodEnd } = periodWindow(today, sched);
|
||||
|
||||
// 2. Owners + units
|
||||
const { data: owners, error: ownersErr } = await supabase
|
||||
.from("owners")
|
||||
.select("id, unit_id, association_id, status")
|
||||
.eq("association_id", associationId)
|
||||
.eq("status", "active");
|
||||
if (ownersErr) {
|
||||
summary.push({ associationId, error: ownersErr.message });
|
||||
continue;
|
||||
}
|
||||
const ownerIds = (owners || []).map((o: any) => o.id);
|
||||
if (ownerIds.length === 0) {
|
||||
summary.push({ associationId, posted: 0, note: "no active owners" });
|
||||
continue;
|
||||
}
|
||||
const ownerById = new Map<string, any>();
|
||||
(owners || []).forEach((o: any) => ownerById.set(o.id, o));
|
||||
|
||||
// 3. Unit fee exclusions
|
||||
const { data: exclusions } = await supabase
|
||||
.from("unit_fee_exclusions")
|
||||
.select("unit_id, fee_type, mode, override_amount, override_percent")
|
||||
.eq("association_id", associationId);
|
||||
const exByUnit: Record<string, { late_fee?: FeeExclusion; interest?: FeeExclusion }> = {};
|
||||
(exclusions || []).forEach((e: any) => {
|
||||
if (!e.unit_id) return;
|
||||
if (!exByUnit[e.unit_id]) exByUnit[e.unit_id] = {};
|
||||
exByUnit[e.unit_id][e.fee_type as "late_fee" | "interest"] = e;
|
||||
});
|
||||
|
||||
// 4. Pull all ledger entries for these owners (paginated)
|
||||
const ledgerByOwner: Record<string, any[]> = {};
|
||||
const PAGE = 1000;
|
||||
// chunk owner ids to avoid URL bloat
|
||||
const chunkSize = 100;
|
||||
for (let i = 0; i < ownerIds.length; i += chunkSize) {
|
||||
const chunk = ownerIds.slice(i, i + chunkSize);
|
||||
let from = 0;
|
||||
while (true) {
|
||||
const { data, error } = await supabase
|
||||
.from("owner_ledger_entries")
|
||||
.select("owner_id, transaction_type, debit, credit, date")
|
||||
.in("owner_id", chunk)
|
||||
.order("date", { ascending: true })
|
||||
.range(from, from + PAGE - 1);
|
||||
if (error) throw error;
|
||||
(data || []).forEach((e: any) => {
|
||||
if (!ledgerByOwner[e.owner_id]) ledgerByOwner[e.owner_id] = [];
|
||||
ledgerByOwner[e.owner_id].push(e);
|
||||
});
|
||||
if (!data || data.length < PAGE) break;
|
||||
from += PAGE;
|
||||
}
|
||||
}
|
||||
|
||||
const inserts: any[] = [];
|
||||
let assocLate = 0;
|
||||
let assocInterest = 0;
|
||||
let skippedAlreadyPosted = 0;
|
||||
let skippedNoBalance = 0;
|
||||
let waived = 0;
|
||||
|
||||
for (const owner of owners || []) {
|
||||
const entries = ledgerByOwner[owner.id] || [];
|
||||
let balance = 0;
|
||||
let lastPaymentDate: string | null = null;
|
||||
let postedLateThisPeriod = false;
|
||||
let postedInterestThisPeriod = false;
|
||||
|
||||
for (const e of entries) {
|
||||
balance += (Number(e.debit) || 0) - (Number(e.credit) || 0);
|
||||
const t = (e.transaction_type || "").toLowerCase();
|
||||
const d = e.date as string | null;
|
||||
if (!d) continue;
|
||||
if ((t.includes("payment") || (Number(e.credit) > 0 && !t)) && (!lastPaymentDate || d > lastPaymentDate)) {
|
||||
lastPaymentDate = d;
|
||||
}
|
||||
if (d >= periodStart && d <= periodEnd) {
|
||||
if (t.includes("late")) postedLateThisPeriod = true;
|
||||
if (t.includes("interest")) postedInterestThisPeriod = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (balance <= 0) {
|
||||
skippedNoBalance++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const unitId = owner.unit_id;
|
||||
const unitEx = unitId ? exByUnit[unitId] || {} : {};
|
||||
|
||||
// ---------- Late fee ----------
|
||||
if (rule.late_fee_enabled && !postedLateThisPeriod) {
|
||||
// Determine if past trigger
|
||||
const triggerDays = Number(rule.late_fee_trigger_days || 0);
|
||||
let triggered = false;
|
||||
if (lastPaymentDate) {
|
||||
const ageMs = today.getTime() - new Date(lastPaymentDate + "T12:00:00Z").getTime();
|
||||
const ageDays = Math.floor(ageMs / 86400000);
|
||||
triggered = ageDays >= triggerDays;
|
||||
} else {
|
||||
triggered = true; // never paid, has balance
|
||||
}
|
||||
// If non-recurring, only post once ever
|
||||
if (triggered && !rule.late_fee_recurring) {
|
||||
const everPostedLate = entries.some((e) => (e.transaction_type || "").toLowerCase().includes("late"));
|
||||
if (everPostedLate) triggered = false;
|
||||
}
|
||||
if (triggered) {
|
||||
let requested = 0;
|
||||
if ((rule.late_fee_type || "flat") === "percentage") {
|
||||
const pct = Number(rule.late_fee_amount || 0);
|
||||
requested = Number((balance * pct / 100).toFixed(2));
|
||||
if (rule.late_fee_max && requested > Number(rule.late_fee_max)) requested = Number(rule.late_fee_max);
|
||||
} else {
|
||||
requested = Number(rule.late_fee_amount || 0);
|
||||
}
|
||||
const exc = (unitEx.late_fee as FeeExclusion | undefined) || null;
|
||||
const amount = resolveAmount(requested, exc, balance);
|
||||
if (amount > 0) {
|
||||
inserts.push({
|
||||
association_id: associationId,
|
||||
owner_id: owner.id,
|
||||
unit_id: unitId,
|
||||
date: todayISO,
|
||||
transaction_type: "late_fee",
|
||||
description: exc?.mode === "override_amount"
|
||||
? "Late fee (override per unit exclusion) — auto"
|
||||
: "Late fee (auto-applied)",
|
||||
debit: amount,
|
||||
credit: 0,
|
||||
});
|
||||
assocLate += amount;
|
||||
} else if (exc?.mode === "waive") {
|
||||
waived++;
|
||||
}
|
||||
}
|
||||
} else if (postedLateThisPeriod) {
|
||||
skippedAlreadyPosted++;
|
||||
}
|
||||
|
||||
// ---------- Interest ----------
|
||||
if (rule.interest_enabled && !postedInterestThisPeriod) {
|
||||
const graceDays = Number(rule.interest_grace_days || 0);
|
||||
let triggered = false;
|
||||
if (lastPaymentDate) {
|
||||
const ageMs = today.getTime() - new Date(lastPaymentDate + "T12:00:00Z").getTime();
|
||||
const ageDays = Math.floor(ageMs / 86400000);
|
||||
triggered = ageDays >= graceDays;
|
||||
} else {
|
||||
triggered = true;
|
||||
}
|
||||
if (triggered) {
|
||||
const annualRate = Number(rule.interest_rate || 0);
|
||||
const compound = (rule.interest_compound || "monthly").toLowerCase();
|
||||
// interest_rate is stored as APR percent. monthly = APR/12, simple/monthly schedule both apply once per period.
|
||||
let periodicRate = annualRate / 100;
|
||||
if (compound === "monthly" || compound === "simple") periodicRate = annualRate / 100 / 12;
|
||||
else if (compound === "quarterly") periodicRate = annualRate / 100 / 4;
|
||||
else if (compound === "daily") periodicRate = annualRate / 100 / 365 * 30;
|
||||
const requested = Number((balance * periodicRate).toFixed(2));
|
||||
const exc = (unitEx.interest as FeeExclusion | undefined) || null;
|
||||
const amount = resolveAmount(requested, exc, balance);
|
||||
if (amount > 0) {
|
||||
inserts.push({
|
||||
association_id: associationId,
|
||||
owner_id: owner.id,
|
||||
unit_id: unitId,
|
||||
date: todayISO,
|
||||
transaction_type: "interest",
|
||||
description: exc?.mode === "override_amount"
|
||||
? "Interest (override per unit exclusion) — auto"
|
||||
: exc?.mode === "override_percent"
|
||||
? `Interest @ ${(Number(exc.override_percent) * 100).toFixed(2)}% (per unit override) — auto`
|
||||
: `Interest @ ${annualRate}% per annum — auto`,
|
||||
debit: amount,
|
||||
credit: 0,
|
||||
});
|
||||
assocInterest += amount;
|
||||
} else if (exc?.mode === "waive") {
|
||||
waived++;
|
||||
}
|
||||
}
|
||||
} else if (postedInterestThisPeriod) {
|
||||
skippedAlreadyPosted++;
|
||||
}
|
||||
}
|
||||
|
||||
let posted = 0;
|
||||
if (inserts.length && !dryRun) {
|
||||
// batch in chunks of 500
|
||||
for (let i = 0; i < inserts.length; i += 500) {
|
||||
const batch = inserts.slice(i, i + 500);
|
||||
const { error: insErr } = await supabase.from("owner_ledger_entries").insert(batch);
|
||||
if (insErr) {
|
||||
summary.push({ associationId, error: insErr.message, posted });
|
||||
break;
|
||||
}
|
||||
posted += batch.length;
|
||||
}
|
||||
} else {
|
||||
posted = inserts.length;
|
||||
}
|
||||
|
||||
totalLateFees += assocLate;
|
||||
totalInterest += assocInterest;
|
||||
|
||||
summary.push({
|
||||
associationId,
|
||||
period: periodKey(today, sched),
|
||||
posted,
|
||||
late_fees_total: assocLate,
|
||||
interest_total: assocInterest,
|
||||
waived_by_exclusion: waived,
|
||||
skipped_already_posted: skippedAlreadyPosted,
|
||||
skipped_no_balance: skippedNoBalance,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
dry_run: dryRun,
|
||||
rules_processed: eligibleRules.length,
|
||||
total_late_fees: totalLateFees,
|
||||
total_interest: totalInterest,
|
||||
results: summary,
|
||||
}),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("post-recurring-fees error:", err);
|
||||
return new Response(JSON.stringify({ error: (err as Error).message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "npm:react@18.3.1",
|
||||
"types": ["npm:@types/react@18.3.1"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import * as React from 'npm:react@18.3.1'
|
||||
import { renderAsync } from 'npm:@react-email/components@0.0.22'
|
||||
import { corsHeaders } from 'npm:@supabase/supabase-js@2/cors'
|
||||
import { TEMPLATES } from '../_shared/transactional-email-templates/registry.ts'
|
||||
|
||||
// Renders all registered templates with their previewData.
|
||||
// Gated by LOVABLE_API_KEY — only the Go API calls this.
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
const apiKey = Deno.env.get('LOVABLE_API_KEY')
|
||||
if (!apiKey) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Server configuration error' }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Verify the caller is authorized with LOVABLE_API_KEY
|
||||
const authHeader = req.headers.get('Authorization')
|
||||
const token = authHeader?.replace(/^Bearer\s+/i, '')
|
||||
if (token !== apiKey) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const templateNames = Object.keys(TEMPLATES)
|
||||
const results: Array<{
|
||||
templateName: string
|
||||
displayName: string
|
||||
subject: string
|
||||
html: string
|
||||
status: 'ready' | 'preview_data_required' | 'render_failed'
|
||||
errorMessage?: string
|
||||
}> = []
|
||||
|
||||
for (const name of templateNames) {
|
||||
const entry = TEMPLATES[name]
|
||||
const displayName = entry.displayName || name
|
||||
|
||||
if (!entry.previewData) {
|
||||
results.push({
|
||||
templateName: name,
|
||||
displayName,
|
||||
subject: '',
|
||||
html: '',
|
||||
status: 'preview_data_required',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const html = await renderAsync(
|
||||
React.createElement(entry.component, entry.previewData)
|
||||
)
|
||||
const resolvedSubject =
|
||||
typeof entry.subject === 'function'
|
||||
? entry.subject(entry.previewData)
|
||||
: entry.subject
|
||||
|
||||
results.push({
|
||||
templateName: name,
|
||||
displayName,
|
||||
subject: resolvedSubject,
|
||||
html,
|
||||
status: 'ready',
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to render template for preview', {
|
||||
template: name,
|
||||
error: err,
|
||||
})
|
||||
results.push({
|
||||
templateName: name,
|
||||
displayName,
|
||||
subject: '',
|
||||
html: '',
|
||||
status: 'render_failed',
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ templates: results }), {
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.3";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Verify admin/manager
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser(token);
|
||||
if (authError || !user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const { data: roles } = await supabase
|
||||
.from("user_roles")
|
||||
.select("role")
|
||||
.eq("user_id", user.id);
|
||||
|
||||
const isAdmin = roles?.some((r: any) =>
|
||||
["admin", "manager"].includes(r.role)
|
||||
);
|
||||
if (!isAdmin) {
|
||||
return new Response(JSON.stringify({ error: "Forbidden" }), {
|
||||
status: 403,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { association_id, amount_cents, description, enrollment_ids } = body;
|
||||
|
||||
if (!association_id || !amount_cents || amount_cents <= 0) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "association_id and valid amount_cents are required",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Get Stripe mapping
|
||||
const { data: mapping } = await supabase
|
||||
.from("stripe_account_mappings")
|
||||
.select("*")
|
||||
.eq("association_id", association_id)
|
||||
.eq("is_active", true)
|
||||
.maybeSingle();
|
||||
|
||||
if (!mapping?.stripe_secret_key) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "No Stripe configuration for this association" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Get active enrollments
|
||||
let enrollmentQuery = supabase
|
||||
.from("autopay_enrollments")
|
||||
.select("*")
|
||||
.eq("association_id", association_id)
|
||||
.eq("is_active", true);
|
||||
|
||||
if (enrollment_ids?.length) {
|
||||
enrollmentQuery = enrollmentQuery.in("id", enrollment_ids);
|
||||
}
|
||||
|
||||
const { data: enrollments, error: enrollError } = await enrollmentQuery;
|
||||
if (enrollError) throw enrollError;
|
||||
|
||||
if (!enrollments || enrollments.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "No active autopay enrollments found", results: [] }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const stripeSecretKey = mapping.stripe_secret_key;
|
||||
const results: any[] = [];
|
||||
|
||||
for (const enrollment of enrollments) {
|
||||
try {
|
||||
// Calculate fees if applicable
|
||||
let feeCents = 0;
|
||||
const isAch = enrollment.payment_method_type === "us_bank_account";
|
||||
if (mapping.pass_processing_fee) {
|
||||
if (isAch) {
|
||||
feeCents = Math.min(Math.ceil(amount_cents * 0.008), 500);
|
||||
} else {
|
||||
const feePercent = Number(mapping.processing_fee_percent) || 0.029;
|
||||
const feeFixed = Number(mapping.processing_fee_fixed_cents) || 30;
|
||||
const grossed = Math.ceil(
|
||||
(amount_cents + feeFixed) / (1 - feePercent)
|
||||
);
|
||||
feeCents = grossed - amount_cents;
|
||||
}
|
||||
}
|
||||
|
||||
const totalCents = amount_cents + feeCents;
|
||||
|
||||
// Create PaymentIntent with saved payment method
|
||||
const params = new URLSearchParams();
|
||||
params.append("amount", String(totalCents));
|
||||
params.append("currency", "usd");
|
||||
params.append("customer", enrollment.stripe_customer_id);
|
||||
params.append("payment_method", enrollment.stripe_payment_method_id);
|
||||
params.append("off_session", "true");
|
||||
params.append("confirm", "true");
|
||||
params.append(
|
||||
"description",
|
||||
description || "HOA Autopay Assessment"
|
||||
);
|
||||
params.append("metadata[association_id]", association_id);
|
||||
params.append("metadata[enrollment_id]", enrollment.id);
|
||||
params.append("metadata[autopay]", "true");
|
||||
if (enrollment.owner_id)
|
||||
params.append("metadata[owner_id]", enrollment.owner_id);
|
||||
if (enrollment.unit_id)
|
||||
params.append("metadata[unit_id]", enrollment.unit_id);
|
||||
|
||||
if (isAch) {
|
||||
params.append("payment_method_types[]", "us_bank_account");
|
||||
}
|
||||
|
||||
const stripeRes = await fetch(
|
||||
"https://api.stripe.com/v1/payment_intents",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${stripeSecretKey}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: params.toString(),
|
||||
}
|
||||
);
|
||||
|
||||
const stripeData = await stripeRes.json();
|
||||
|
||||
// Record payment
|
||||
await supabase.from("stripe_payments").insert({
|
||||
association_id,
|
||||
owner_id: enrollment.owner_id || null,
|
||||
unit_id: enrollment.unit_id || null,
|
||||
stripe_payment_intent_id: stripeData.id,
|
||||
amount_cents,
|
||||
fee_cents: feeCents,
|
||||
total_cents: totalCents,
|
||||
payment_method_type: isAch ? "us_bank_account" : "card",
|
||||
status: stripeRes.ok ? "succeeded" : "failed",
|
||||
description: description || "HOA Autopay Assessment",
|
||||
});
|
||||
|
||||
results.push({
|
||||
enrollment_id: enrollment.id,
|
||||
owner_id: enrollment.owner_id,
|
||||
success: stripeRes.ok,
|
||||
payment_intent_id: stripeData.id,
|
||||
amount_cents: totalCents,
|
||||
error: stripeRes.ok ? null : stripeData.error?.message,
|
||||
});
|
||||
} catch (chargeErr: any) {
|
||||
results.push({
|
||||
enrollment_id: enrollment.id,
|
||||
owner_id: enrollment.owner_id,
|
||||
success: false,
|
||||
error: chargeErr.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const succeeded = results.filter((r) => r.success).length;
|
||||
const failed = results.filter((r) => !r.success).length;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ results, summary: { total: results.length, succeeded, failed } }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
console.error("Error in process-autopay:", err);
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Internal server error";
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,363 @@
|
||||
import { sendLovableEmail } from 'npm:@lovable.dev/email-js'
|
||||
import { createClient } from 'npm:@supabase/supabase-js@2'
|
||||
|
||||
const MAX_RETRIES = 5
|
||||
const DEFAULT_BATCH_SIZE = 10
|
||||
const DEFAULT_SEND_DELAY_MS = 200
|
||||
const DEFAULT_AUTH_TTL_MINUTES = 15
|
||||
const DEFAULT_TRANSACTIONAL_TTL_MINUTES = 60
|
||||
|
||||
// Check if an error is a rate-limit (429) response.
|
||||
// Uses EmailAPIError.status when available (email-js >=0.x with structured errors),
|
||||
// falls back to parsing the error message for older versions.
|
||||
function isRateLimited(error: unknown): boolean {
|
||||
if (error && typeof error === 'object' && 'status' in error) {
|
||||
return (error as { status: number }).status === 429
|
||||
}
|
||||
return error instanceof Error && error.message.includes('429')
|
||||
}
|
||||
|
||||
// Check if an error is a forbidden (403) response. Retrying won't help.
|
||||
// Move straight to DLQ.
|
||||
function isForbidden(error: unknown): boolean {
|
||||
if (error && typeof error === 'object' && 'status' in error) {
|
||||
return (error as { status: number }).status === 403
|
||||
}
|
||||
return error instanceof Error && error.message.includes('403')
|
||||
}
|
||||
|
||||
// Extract Retry-After seconds from a structured EmailAPIError, or default to 60s.
|
||||
function getRetryAfterSeconds(error: unknown): number {
|
||||
if (error && typeof error === 'object' && 'retryAfterSeconds' in error) {
|
||||
return (error as { retryAfterSeconds: number | null }).retryAfterSeconds ?? 60
|
||||
}
|
||||
return 60
|
||||
}
|
||||
|
||||
function parseJwtClaims(token: string): Record<string, unknown> | null {
|
||||
const parts = token.split('.')
|
||||
if (parts.length < 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = parts[1]
|
||||
.replaceAll('-', '+')
|
||||
.replaceAll('_', '/')
|
||||
.padEnd(Math.ceil(parts[1].length / 4) * 4, '=')
|
||||
|
||||
return JSON.parse(atob(payload)) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Move a message to the dead letter queue and log the reason.
|
||||
async function moveToDlq(
|
||||
supabase: ReturnType<typeof createClient>,
|
||||
queue: string,
|
||||
msg: { msg_id: number; message: Record<string, unknown> },
|
||||
reason: string
|
||||
): Promise<void> {
|
||||
const payload = msg.message
|
||||
await supabase.from('email_send_log').insert({
|
||||
message_id: payload.message_id,
|
||||
template_name: (payload.label || queue) as string,
|
||||
recipient_email: payload.to,
|
||||
status: 'dlq',
|
||||
error_message: reason,
|
||||
})
|
||||
const { error } = await supabase.rpc('move_to_dlq', {
|
||||
source_queue: queue,
|
||||
dlq_name: `${queue}_dlq`,
|
||||
message_id: msg.msg_id,
|
||||
payload,
|
||||
})
|
||||
if (error) {
|
||||
console.error('Failed to move message to DLQ', { queue, msg_id: msg.msg_id, reason, error })
|
||||
}
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const apiKey = Deno.env.get('LOVABLE_API_KEY')
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')
|
||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')
|
||||
|
||||
if (!apiKey || !supabaseUrl || !supabaseServiceKey) {
|
||||
console.error('Missing required environment variables')
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Server configuration error' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
const authHeader = req.headers.get('Authorization')
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Unauthorized' }),
|
||||
{ status: 401, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
// Defense in depth: verify_jwt=true already requires a valid JWT at the
|
||||
// gateway layer. This adds an explicit role check so only service-role
|
||||
// callers can trigger queue processing.
|
||||
const token = authHeader.slice('Bearer '.length).trim()
|
||||
const claims = parseJwtClaims(token)
|
||||
if (claims?.role !== 'service_role') {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Forbidden' }),
|
||||
{ status: 403, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
// 1. Check rate-limit cooldown and read queue config
|
||||
const { data: state } = await supabase
|
||||
.from('email_send_state')
|
||||
.select('retry_after_until, batch_size, send_delay_ms, auth_email_ttl_minutes, transactional_email_ttl_minutes')
|
||||
.single()
|
||||
|
||||
if (state?.retry_after_until && new Date(state.retry_after_until) > new Date()) {
|
||||
return new Response(
|
||||
JSON.stringify({ skipped: true, reason: 'rate_limited' }),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
const batchSize = state?.batch_size ?? DEFAULT_BATCH_SIZE
|
||||
const sendDelayMs = state?.send_delay_ms ?? DEFAULT_SEND_DELAY_MS
|
||||
const ttlMinutes: Record<string, number> = {
|
||||
auth_emails: state?.auth_email_ttl_minutes ?? DEFAULT_AUTH_TTL_MINUTES,
|
||||
transactional_emails: state?.transactional_email_ttl_minutes ?? DEFAULT_TRANSACTIONAL_TTL_MINUTES,
|
||||
}
|
||||
|
||||
let totalProcessed = 0
|
||||
|
||||
// 2. Process auth_emails first (priority), then transactional_emails
|
||||
for (const queue of ['auth_emails', 'transactional_emails']) {
|
||||
const { data: messages, error: readError } = await supabase.rpc('read_email_batch', {
|
||||
queue_name: queue,
|
||||
batch_size: batchSize,
|
||||
vt: 30,
|
||||
})
|
||||
|
||||
if (readError) {
|
||||
console.error('Failed to read email batch', { queue, error: readError })
|
||||
continue
|
||||
}
|
||||
|
||||
if (!messages?.length) continue
|
||||
|
||||
// Retry budget is based on real send failures, not pgmq read_ct.
|
||||
// read_ct increments for every message in a claimed batch, including
|
||||
// messages not attempted when a 429 stops processing early.
|
||||
const messageIds = Array.from(
|
||||
new Set(
|
||||
messages
|
||||
.map((msg) =>
|
||||
msg?.message?.message_id && typeof msg.message.message_id === 'string'
|
||||
? msg.message.message_id
|
||||
: null
|
||||
)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
)
|
||||
)
|
||||
const failedAttemptsByMessageId = new Map<string, number>()
|
||||
if (messageIds.length > 0) {
|
||||
const { data: failedRows, error: failedRowsError } = await supabase
|
||||
.from('email_send_log')
|
||||
.select('message_id')
|
||||
.in('message_id', messageIds)
|
||||
.eq('status', 'failed')
|
||||
|
||||
if (failedRowsError) {
|
||||
console.error('Failed to load failed-attempt counters', {
|
||||
queue,
|
||||
error: failedRowsError,
|
||||
})
|
||||
} else {
|
||||
for (const row of failedRows ?? []) {
|
||||
const messageId = row?.message_id
|
||||
if (typeof messageId !== 'string' || !messageId) continue
|
||||
failedAttemptsByMessageId.set(
|
||||
messageId,
|
||||
(failedAttemptsByMessageId.get(messageId) ?? 0) + 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i]
|
||||
const payload = msg.message
|
||||
const failedAttempts =
|
||||
payload?.message_id && typeof payload.message_id === 'string'
|
||||
? (failedAttemptsByMessageId.get(payload.message_id) ?? 0)
|
||||
: msg.read_ct ?? 0
|
||||
|
||||
// Drop expired messages (TTL exceeded).
|
||||
// Prefer payload.queued_at when present; fall back to PGMQ's enqueued_at
|
||||
// which is always set by the queue.
|
||||
const queuedAt = payload.queued_at ?? msg.enqueued_at
|
||||
if (queuedAt) {
|
||||
const ageMs = Date.now() - new Date(queuedAt).getTime()
|
||||
const maxAgeMs = ttlMinutes[queue] * 60 * 1000
|
||||
if (ageMs > maxAgeMs) {
|
||||
console.warn('Email expired (TTL exceeded)', {
|
||||
queue,
|
||||
msg_id: msg.msg_id,
|
||||
queued_at: queuedAt,
|
||||
ttl_minutes: ttlMinutes[queue],
|
||||
})
|
||||
await moveToDlq(supabase, queue, msg, `TTL exceeded (${ttlMinutes[queue]} minutes)`)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Move to DLQ if max failed send attempts reached.
|
||||
if (failedAttempts >= MAX_RETRIES) {
|
||||
await moveToDlq(supabase, queue, msg, `Max retries (${MAX_RETRIES}) exceeded (attempted ${failedAttempts} times)`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Guard: skip if another worker already sent this message (VT expired race)
|
||||
if (payload.message_id) {
|
||||
const { data: alreadySent } = await supabase
|
||||
.from('email_send_log')
|
||||
.select('id')
|
||||
.eq('message_id', payload.message_id)
|
||||
.eq('status', 'sent')
|
||||
.maybeSingle()
|
||||
|
||||
if (alreadySent) {
|
||||
console.warn('Skipping duplicate send (already sent)', {
|
||||
queue,
|
||||
msg_id: msg.msg_id,
|
||||
message_id: payload.message_id,
|
||||
})
|
||||
const { error: dupDelError } = await supabase.rpc('delete_email', {
|
||||
queue_name: queue,
|
||||
message_id: msg.msg_id,
|
||||
})
|
||||
if (dupDelError) {
|
||||
console.error('Failed to delete duplicate message from queue', { queue, msg_id: msg.msg_id, error: dupDelError })
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await sendLovableEmail(
|
||||
{
|
||||
run_id: payload.run_id,
|
||||
to: payload.to,
|
||||
from: payload.from,
|
||||
sender_domain: payload.sender_domain,
|
||||
subject: payload.subject,
|
||||
html: payload.html,
|
||||
text: payload.text,
|
||||
purpose: payload.purpose,
|
||||
label: payload.label,
|
||||
idempotency_key: payload.idempotency_key,
|
||||
unsubscribe_token: payload.unsubscribe_token,
|
||||
message_id: payload.message_id,
|
||||
},
|
||||
// sendUrl is optional — when LOVABLE_SEND_URL is not set, the library
|
||||
// falls back to the default Lovable API endpoint (https://api.lovable.dev).
|
||||
// Set LOVABLE_SEND_URL as a Supabase secret to override (e.g. for local dev).
|
||||
{ apiKey, sendUrl: Deno.env.get('LOVABLE_SEND_URL') }
|
||||
)
|
||||
|
||||
// Log success
|
||||
await supabase.from('email_send_log').insert({
|
||||
message_id: payload.message_id,
|
||||
template_name: payload.label || queue,
|
||||
recipient_email: payload.to,
|
||||
status: 'sent',
|
||||
})
|
||||
|
||||
// Delete from queue
|
||||
const { error: delError } = await supabase.rpc('delete_email', {
|
||||
queue_name: queue,
|
||||
message_id: msg.msg_id,
|
||||
})
|
||||
if (delError) {
|
||||
console.error('Failed to delete sent message from queue', { queue, msg_id: msg.msg_id, error: delError })
|
||||
}
|
||||
totalProcessed++
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
console.error('Email send failed', {
|
||||
queue,
|
||||
msg_id: msg.msg_id,
|
||||
read_ct: msg.read_ct,
|
||||
failed_attempts: failedAttempts,
|
||||
error: errorMsg,
|
||||
})
|
||||
|
||||
if (isRateLimited(error)) {
|
||||
await supabase.from('email_send_log').insert({
|
||||
message_id: payload.message_id,
|
||||
template_name: payload.label || queue,
|
||||
recipient_email: payload.to,
|
||||
status: 'rate_limited',
|
||||
error_message: errorMsg.slice(0, 1000),
|
||||
})
|
||||
|
||||
const retryAfterSecs = getRetryAfterSeconds(error)
|
||||
await supabase
|
||||
.from('email_send_state')
|
||||
.update({
|
||||
retry_after_until: new Date(
|
||||
Date.now() + retryAfterSecs * 1000
|
||||
).toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', 1)
|
||||
|
||||
// Stop processing — remaining messages stay in queue (VT expires, retried next cycle)
|
||||
return new Response(
|
||||
JSON.stringify({ processed: totalProcessed, stopped: 'rate_limited' }),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
// 403s are permanent configuration or authorization failures for this
|
||||
// message, so move straight to DLQ and stop processing the rest of the batch.
|
||||
if (isForbidden(error)) {
|
||||
await moveToDlq(supabase, queue, msg, errorMsg.slice(0, 1000))
|
||||
return new Response(
|
||||
JSON.stringify({ processed: totalProcessed, stopped: 'forbidden' }),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
// Log non-429 failures to track real retry attempts.
|
||||
await supabase.from('email_send_log').insert({
|
||||
message_id: payload.message_id,
|
||||
template_name: payload.label || queue,
|
||||
recipient_email: payload.to,
|
||||
status: 'failed',
|
||||
error_message: errorMsg.slice(0, 1000),
|
||||
})
|
||||
if (payload?.message_id && typeof payload.message_id === 'string') {
|
||||
failedAttemptsByMessageId.set(payload.message_id, failedAttempts + 1)
|
||||
}
|
||||
|
||||
// Non-429 errors: message stays invisible until VT expires, then retried
|
||||
}
|
||||
|
||||
// Small delay between sends to smooth bursts
|
||||
if (i < messages.length - 1) {
|
||||
await new Promise((r) => setTimeout(r, sendDelayMs))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ processed: totalProcessed }),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.99.1";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response("ok", { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, serviceKey);
|
||||
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
// Find all recurring expenses where next_date <= today
|
||||
const { data: recurring, error: fetchErr } = await supabase
|
||||
.from("billable_expenses")
|
||||
.select("*")
|
||||
.eq("is_recurring", true)
|
||||
.lte("recurring_next_date", today);
|
||||
|
||||
if (fetchErr) throw fetchErr;
|
||||
if (!recurring || recurring.length === 0) {
|
||||
return new Response(JSON.stringify({ created: 0 }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
let created = 0;
|
||||
|
||||
for (const expense of recurring) {
|
||||
// Skip if past end date
|
||||
if (expense.recurring_end_date && expense.recurring_next_date > expense.recurring_end_date) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create the new expense entry
|
||||
const { error: insertErr } = await supabase.from("billable_expenses").insert({
|
||||
association_id: expense.association_id,
|
||||
date: expense.recurring_next_date,
|
||||
description: expense.description,
|
||||
amount: expense.amount,
|
||||
category: expense.category,
|
||||
billable_type: expense.billable_type,
|
||||
is_credit: expense.is_credit,
|
||||
credit_reason: expense.credit_reason,
|
||||
quantity: expense.quantity,
|
||||
unit_price: expense.unit_price,
|
||||
vendor_name: expense.vendor_name,
|
||||
address: expense.address,
|
||||
status: "pending",
|
||||
recurring_parent_id: expense.id,
|
||||
is_recurring: false,
|
||||
});
|
||||
|
||||
if (insertErr) {
|
||||
console.error(`Failed to create recurring expense for ${expense.id}:`, insertErr);
|
||||
continue;
|
||||
}
|
||||
|
||||
created++;
|
||||
|
||||
// Advance the next date by one month
|
||||
const nextDate = new Date(expense.recurring_next_date + "T12:00:00");
|
||||
nextDate.setMonth(nextDate.getMonth() + 1);
|
||||
// Clamp day
|
||||
const day = expense.recurring_day || 1;
|
||||
const maxDay = new Date(nextDate.getFullYear(), nextDate.getMonth() + 1, 0).getDate();
|
||||
nextDate.setDate(Math.min(day, maxDay));
|
||||
const newNextDate = nextDate.toISOString().split("T")[0];
|
||||
|
||||
await supabase
|
||||
.from("billable_expenses")
|
||||
.update({ recurring_next_date: newNextDate })
|
||||
.eq("id", expense.id);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ created, processed: recurring.length }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error processing recurring expenses:", err);
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
||||
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const QBO_CLIENT_ID = Deno.env.get("QBO_CLIENT_ID");
|
||||
const QBO_CLIENT_SECRET = Deno.env.get("QBO_CLIENT_SECRET");
|
||||
|
||||
if (!QBO_CLIENT_ID || !QBO_CLIENT_SECRET) {
|
||||
return new Response(JSON.stringify({ error: "QBO credentials not configured" }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const supabaseAdmin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
|
||||
const url = new URL(req.url);
|
||||
|
||||
try {
|
||||
if (req.method === "GET") {
|
||||
// Handle OAuth callback from QBO
|
||||
const code = url.searchParams.get("code");
|
||||
const realmId = url.searchParams.get("realmId");
|
||||
|
||||
if (!code) {
|
||||
return new Response("Missing authorization code", { status: 400 });
|
||||
}
|
||||
|
||||
// Determine the redirect URI (same as this function URL)
|
||||
const redirectUri = `${SUPABASE_URL}/functions/v1/qbo-auth`;
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokenResp = await fetch("https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Authorization": `Basic ${btoa(`${QBO_CLIENT_ID}:${QBO_CLIENT_SECRET}`)}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResp.ok) {
|
||||
const errText = await tokenResp.text();
|
||||
console.error("Token exchange failed:", errText);
|
||||
return new Response(`Token exchange failed: ${errText}`, { status: 500 });
|
||||
}
|
||||
|
||||
const tokens = await tokenResp.json();
|
||||
const expiry = (Date.now() + tokens.expires_in * 1000).toString();
|
||||
|
||||
// Store tokens in company_settings
|
||||
const entries: Record<string, string> = {
|
||||
qbo_access_token: tokens.access_token,
|
||||
qbo_refresh_token: tokens.refresh_token,
|
||||
qbo_token_expiry: expiry,
|
||||
};
|
||||
|
||||
if (realmId) {
|
||||
entries.qbo_realm_id = realmId;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
const { data: existing } = await supabaseAdmin.from("company_settings").select("id").eq("key", key).maybeSingle();
|
||||
if (existing) {
|
||||
await supabaseAdmin.from("company_settings").update({ value }).eq("key", key);
|
||||
} else {
|
||||
await supabaseAdmin.from("company_settings").insert({ key, value });
|
||||
}
|
||||
}
|
||||
|
||||
// Return a success HTML page
|
||||
return new Response(
|
||||
`<!DOCTYPE html><html><body style="font-family:sans-serif;text-align:center;padding:60px">
|
||||
<h1>✅ QuickBooks Connected!</h1>
|
||||
<p>Your QuickBooks Online account has been successfully linked.</p>
|
||||
<p>You can close this window and return to the app.</p>
|
||||
<script>setTimeout(()=>window.close(),3000)</script>
|
||||
</body></html>`,
|
||||
{ headers: { "Content-Type": "text/html" } },
|
||||
);
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
// Generate the QBO authorization URL
|
||||
const body = await req.json();
|
||||
const { action } = body;
|
||||
|
||||
if (action === "get_auth_url") {
|
||||
const redirectUri = `${SUPABASE_URL}/functions/v1/qbo-auth`;
|
||||
const authUrl = `https://appcenter.intuit.com/connect/oauth2?` +
|
||||
`client_id=${QBO_CLIENT_ID}` +
|
||||
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||
`&scope=com.intuit.quickbooks.accounting` +
|
||||
`&response_type=code` +
|
||||
`&state=qbo_connect`;
|
||||
|
||||
return new Response(JSON.stringify({ authUrl, redirectUri }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "check_status") {
|
||||
const { data: tokenSetting } = await supabaseAdmin
|
||||
.from("company_settings")
|
||||
.select("value")
|
||||
.eq("key", "qbo_refresh_token")
|
||||
.maybeSingle();
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
connected: !!tokenSetting?.value,
|
||||
}), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ error: "Method not allowed" }), {
|
||||
status: 405,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("QBO auth error:", err);
|
||||
return new Response(JSON.stringify({ error: err instanceof Error ? err.message : "Unknown error" }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
const QBO_BASE_URL = "https://quickbooks.api.intuit.com";
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const authHeader = req.headers.get("authorization");
|
||||
if (!authHeader) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const QBO_CLIENT_ID = Deno.env.get("QBO_CLIENT_ID");
|
||||
const QBO_CLIENT_SECRET = Deno.env.get("QBO_CLIENT_SECRET");
|
||||
const QBO_REALM_ID = Deno.env.get("QBO_REALM_ID");
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
||||
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
|
||||
if (!QBO_CLIENT_ID || !QBO_CLIENT_SECRET || !QBO_REALM_ID) {
|
||||
return new Response(JSON.stringify({ error: "QBO credentials not configured" }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const supabaseAdmin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
|
||||
|
||||
// Verify the user is an admin
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
const { data: { user }, error: authError } = await createClient(SUPABASE_URL, Deno.env.get("SUPABASE_ANON_KEY")!, {
|
||||
global: { headers: { Authorization: `Bearer ${token}` } },
|
||||
}).auth.getUser();
|
||||
|
||||
if (authError || !user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const { data: roles } = await supabaseAdmin.from("user_roles").select("role").eq("user_id", user.id);
|
||||
const isAdmin = roles?.some((r: any) => r.role === "admin");
|
||||
if (!isAdmin) {
|
||||
return new Response(JSON.stringify({ error: "Forbidden: admin only" }), {
|
||||
status: 403,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { action } = body;
|
||||
|
||||
// Get or refresh QBO access token
|
||||
const accessToken = await getQBOAccessToken(supabaseAdmin, QBO_CLIENT_ID, QBO_CLIENT_SECRET);
|
||||
if (!accessToken) {
|
||||
return new Response(JSON.stringify({
|
||||
error: "QBO authentication failed. You may need to re-authorize.",
|
||||
reconnectRequired: true,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
let result: any = { message: "Unknown action" };
|
||||
|
||||
switch (action) {
|
||||
case "sync_all": {
|
||||
// Pull invoices from QBO
|
||||
const invoices = await fetchQBOData(accessToken, QBO_REALM_ID, "Invoice");
|
||||
const payments = await fetchQBOData(accessToken, QBO_REALM_ID, "Payment");
|
||||
const purchases = await fetchQBOData(accessToken, QBO_REALM_ID, "Purchase");
|
||||
|
||||
let synced = 0;
|
||||
|
||||
// Sync invoices as credits (money owed to us)
|
||||
if (invoices?.QueryResponse?.Invoice) {
|
||||
for (const inv of invoices.QueryResponse.Invoice) {
|
||||
const existing = await supabaseAdmin.from("company_bank_transactions")
|
||||
.select("id").eq("qbo_id", `INV-${inv.Id}`).maybeSingle();
|
||||
|
||||
if (!existing.data) {
|
||||
await supabaseAdmin.from("company_bank_transactions").insert({
|
||||
date: inv.TxnDate,
|
||||
transaction_type: "invoice_payment",
|
||||
description: `QBO Invoice #${inv.DocNumber || inv.Id} - ${inv.CustomerRef?.name || "Customer"}`,
|
||||
reference_number: inv.DocNumber || null,
|
||||
credit: inv.TotalAmt || 0,
|
||||
debit: 0,
|
||||
category: "QBO Invoice",
|
||||
qbo_id: `INV-${inv.Id}`,
|
||||
qbo_sync_status: "synced",
|
||||
});
|
||||
synced++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync payments as credits
|
||||
if (payments?.QueryResponse?.Payment) {
|
||||
for (const pmt of payments.QueryResponse.Payment) {
|
||||
const existing = await supabaseAdmin.from("company_bank_transactions")
|
||||
.select("id").eq("qbo_id", `PMT-${pmt.Id}`).maybeSingle();
|
||||
|
||||
if (!existing.data) {
|
||||
await supabaseAdmin.from("company_bank_transactions").insert({
|
||||
date: pmt.TxnDate,
|
||||
transaction_type: "deposit",
|
||||
description: `QBO Payment - ${pmt.CustomerRef?.name || "Customer"}`,
|
||||
reference_number: pmt.PaymentRefNum || null,
|
||||
credit: pmt.TotalAmt || 0,
|
||||
debit: 0,
|
||||
category: "QBO Payment",
|
||||
qbo_id: `PMT-${pmt.Id}`,
|
||||
qbo_sync_status: "synced",
|
||||
});
|
||||
synced++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync purchases as debits
|
||||
if (purchases?.QueryResponse?.Purchase) {
|
||||
for (const pur of purchases.QueryResponse.Purchase) {
|
||||
const existing = await supabaseAdmin.from("company_bank_transactions")
|
||||
.select("id").eq("qbo_id", `PUR-${pur.Id}`).maybeSingle();
|
||||
|
||||
if (!existing.data) {
|
||||
await supabaseAdmin.from("company_bank_transactions").insert({
|
||||
date: pur.TxnDate,
|
||||
transaction_type: "payment",
|
||||
description: `QBO Purchase - ${pur.EntityRef?.name || "Vendor"}`,
|
||||
reference_number: pur.DocNumber || null,
|
||||
debit: pur.TotalAmt || 0,
|
||||
credit: 0,
|
||||
category: "QBO Purchase",
|
||||
qbo_id: `PUR-${pur.Id}`,
|
||||
qbo_sync_status: "synced",
|
||||
});
|
||||
synced++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Push unsynced local transactions to QBO
|
||||
const { data: unsynced } = await supabaseAdmin.from("company_bank_transactions")
|
||||
.select("*")
|
||||
.is("qbo_id", null)
|
||||
.eq("qbo_sync_status", "pending");
|
||||
|
||||
let pushed = 0;
|
||||
if (unsynced) {
|
||||
for (const txn of unsynced) {
|
||||
try {
|
||||
const qboResult = await pushToQBO(accessToken, QBO_REALM_ID, txn);
|
||||
if (qboResult?.Id) {
|
||||
await supabaseAdmin.from("company_bank_transactions")
|
||||
.update({ qbo_id: `LOCAL-${qboResult.Id}`, qbo_sync_status: "synced" })
|
||||
.eq("id", txn.id);
|
||||
pushed++;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Push to QBO failed for txn:", txn.id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log the sync
|
||||
await supabaseAdmin.from("qbo_sync_log").insert({
|
||||
sync_type: "full_sync",
|
||||
direction: "bidirectional",
|
||||
entity_type: "all",
|
||||
status: "completed",
|
||||
payload: { invoices_pulled: synced, transactions_pushed: pushed },
|
||||
});
|
||||
|
||||
result = {
|
||||
message: `Sync complete: ${synced} transactions pulled from QBO, ${pushed} pushed to QBO`,
|
||||
pulled: synced,
|
||||
pushed,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "test_connection": {
|
||||
const companyInfo = await fetchQBOData(accessToken, QBO_REALM_ID, "CompanyInfo");
|
||||
result = {
|
||||
message: "Connection successful",
|
||||
company: companyInfo?.QueryResponse?.CompanyInfo?.[0]?.CompanyName || "Connected",
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
result = { error: `Unknown action: ${action}` };
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(result), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error("QBO sync error:", err);
|
||||
return new Response(JSON.stringify({ error: err instanceof Error ? err.message : "Unknown error" }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function getQBOAccessToken(
|
||||
supabaseAdmin: any,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
): Promise<string | null> {
|
||||
// Check for stored refresh token
|
||||
const { data: settings } = await supabaseAdmin
|
||||
.from("company_settings")
|
||||
.select("*")
|
||||
.in("key", ["qbo_access_token", "qbo_refresh_token", "qbo_token_expiry"]);
|
||||
|
||||
const tokenMap: Record<string, string> = {};
|
||||
settings?.forEach((s: any) => { tokenMap[s.key] = s.value; });
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// If we have a valid access token, use it
|
||||
if (tokenMap.qbo_access_token && tokenMap.qbo_token_expiry && parseInt(tokenMap.qbo_token_expiry) > now) {
|
||||
return tokenMap.qbo_access_token;
|
||||
}
|
||||
|
||||
// Try to refresh
|
||||
if (tokenMap.qbo_refresh_token) {
|
||||
try {
|
||||
const resp = await fetch("https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Authorization": `Basic ${btoa(`${clientId}:${clientSecret}`)}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: tokenMap.qbo_refresh_token,
|
||||
}),
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
const tokens = await resp.json();
|
||||
const expiry = (now + tokens.expires_in * 1000).toString();
|
||||
|
||||
// Upsert tokens
|
||||
for (const [key, value] of Object.entries({
|
||||
qbo_access_token: tokens.access_token,
|
||||
qbo_refresh_token: tokens.refresh_token,
|
||||
qbo_token_expiry: expiry,
|
||||
})) {
|
||||
const { data: existing } = await supabaseAdmin.from("company_settings").select("id").eq("key", key).maybeSingle();
|
||||
if (existing) {
|
||||
await supabaseAdmin.from("company_settings").update({ value }).eq("key", key);
|
||||
} else {
|
||||
await supabaseAdmin.from("company_settings").insert({ key, value });
|
||||
}
|
||||
}
|
||||
|
||||
return tokens.access_token;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Token refresh failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchQBOData(accessToken: string, realmId: string, entity: string): Promise<any> {
|
||||
const query = `SELECT * FROM ${entity} MAXRESULTS 1000`;
|
||||
const resp = await fetch(
|
||||
`${QBO_BASE_URL}/v3/company/${realmId}/query?query=${encodeURIComponent(query)}`,
|
||||
{
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
console.error(`QBO query failed for ${entity} [${resp.status}]:`, text);
|
||||
return null;
|
||||
}
|
||||
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function pushToQBO(accessToken: string, realmId: string, txn: any): Promise<any> {
|
||||
// Push as a JournalEntry to QBO
|
||||
const entry = {
|
||||
Line: [
|
||||
{
|
||||
DetailType: "JournalEntryLineDetail",
|
||||
Amount: txn.debit > 0 ? txn.debit : txn.credit,
|
||||
JournalEntryLineDetail: {
|
||||
PostingType: txn.debit > 0 ? "Debit" : "Credit",
|
||||
},
|
||||
Description: txn.description || "Synced from ACM",
|
||||
},
|
||||
],
|
||||
TxnDate: txn.date,
|
||||
};
|
||||
|
||||
const resp = await fetch(
|
||||
`${QBO_BASE_URL}/v3/company/${realmId}/journalentry?minorversion=73`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(entry),
|
||||
},
|
||||
);
|
||||
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error(`QBO push failed [${resp.status}]: ${text}`);
|
||||
}
|
||||
|
||||
const result = await resp.json();
|
||||
return result.JournalEntry;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.3";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||
|
||||
try {
|
||||
const supabase = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
|
||||
);
|
||||
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser(
|
||||
authHeader.replace("Bearer ", ""),
|
||||
);
|
||||
if (authError || !user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const { payment_intent_id } = await req.json();
|
||||
if (!payment_intent_id) {
|
||||
return new Response(JSON.stringify({ error: "payment_intent_id required" }), {
|
||||
status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Load the stripe_payments row created when intent was made
|
||||
const { data: payment, error: pErr } = await supabase
|
||||
.from("stripe_payments")
|
||||
.select("*")
|
||||
.eq("stripe_payment_intent_id", payment_intent_id)
|
||||
.maybeSingle();
|
||||
|
||||
if (pErr || !payment) {
|
||||
return new Response(JSON.stringify({ error: "Payment record not found" }), {
|
||||
status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Get Stripe secret key for the association
|
||||
const { data: mapping } = await supabase
|
||||
.from("stripe_account_mappings")
|
||||
.select("stripe_secret_key, stripe_account_id")
|
||||
.eq("association_id", payment.association_id)
|
||||
.eq("is_active", true)
|
||||
.maybeSingle();
|
||||
|
||||
if (!mapping?.stripe_secret_key) {
|
||||
return new Response(JSON.stringify({ error: "Stripe not configured for association" }), {
|
||||
status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Verify with Stripe that the PaymentIntent succeeded (or is processing for ACH)
|
||||
const piRes = await fetch(`https://api.stripe.com/v1/payment_intents/${payment_intent_id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${mapping.stripe_secret_key}`,
|
||||
...(mapping.stripe_account_id?.startsWith("acct_") ? { "Stripe-Account": mapping.stripe_account_id } : {}),
|
||||
},
|
||||
});
|
||||
const pi = await piRes.json();
|
||||
if (!piRes.ok) {
|
||||
console.error("Stripe lookup error:", pi);
|
||||
return new Response(JSON.stringify({ error: pi.error?.message || "Stripe error" }), {
|
||||
status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const status = pi.status as string;
|
||||
const recordable = status === "succeeded" || status === "processing";
|
||||
if (!recordable) {
|
||||
await supabase.from("stripe_payments")
|
||||
.update({ status, updated_at: new Date().toISOString() })
|
||||
.eq("id", payment.id);
|
||||
return new Response(JSON.stringify({ ok: false, status }), {
|
||||
status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Update stripe_payments status
|
||||
await supabase.from("stripe_payments")
|
||||
.update({ status, updated_at: new Date().toISOString() })
|
||||
.eq("id", payment.id);
|
||||
|
||||
// Idempotency: skip if a ledger entry already exists for this PI
|
||||
const { data: existing } = await supabase
|
||||
.from("owner_ledger_entries")
|
||||
.select("id")
|
||||
.eq("reference_type", "stripe_payment")
|
||||
.eq("reference_id", payment_intent_id)
|
||||
.maybeSingle();
|
||||
|
||||
if (existing) {
|
||||
return new Response(JSON.stringify({ ok: true, already_recorded: true }), {
|
||||
status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (!payment.owner_id) {
|
||||
return new Response(JSON.stringify({ ok: true, skipped: "no owner" }), {
|
||||
status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const netAmount = Number(payment.amount_cents) / 100;
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const methodLabel = payment.payment_method_type === "us_bank_account" ? "ACH" : "Card";
|
||||
|
||||
const { error: ledgerErr } = await supabase.from("owner_ledger_entries").insert({
|
||||
owner_id: payment.owner_id,
|
||||
association_id: payment.association_id,
|
||||
unit_id: payment.unit_id,
|
||||
date: today,
|
||||
transaction_type: "payment",
|
||||
description: `Online Payment (${methodLabel}) — ${payment.description || "Assessment"}`,
|
||||
debit: 0,
|
||||
credit: netAmount,
|
||||
reference_type: "stripe_payment",
|
||||
reference_id: payment_intent_id,
|
||||
});
|
||||
|
||||
if (ledgerErr) {
|
||||
console.error("Ledger insert error:", ledgerErr);
|
||||
return new Response(JSON.stringify({ error: ledgerErr.message }), {
|
||||
status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ ok: true, recorded: true, status }), {
|
||||
status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("record-stripe-payment error:", err);
|
||||
return new Response(JSON.stringify({ error: (err as Error).message }), {
|
||||
status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.99.1";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
function json(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
|
||||
|
||||
try {
|
||||
const { code, email, password, full_name } = await req.json();
|
||||
if (!code || !email || !password) {
|
||||
return json({ error: "Missing required fields" }, 400);
|
||||
}
|
||||
if (String(password).length < 8) {
|
||||
return json({ error: "Password must be at least 8 characters" }, 400);
|
||||
}
|
||||
|
||||
const admin = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
|
||||
{ auth: { autoRefreshToken: false, persistSession: false } }
|
||||
);
|
||||
|
||||
// Validate code
|
||||
const { data: codeRow, error: codeErr } = await admin
|
||||
.from("signup_codes")
|
||||
.select("*")
|
||||
.eq("code", String(code).trim())
|
||||
.maybeSingle();
|
||||
|
||||
if (codeErr || !codeRow) return json({ error: "Invalid sign-up code" }, 404);
|
||||
if (codeRow.redeemed_at) return json({ error: "This code has already been used" }, 410);
|
||||
if (codeRow.expires_at && new Date(codeRow.expires_at) < new Date()) {
|
||||
return json({ error: "This code has expired" }, 410);
|
||||
}
|
||||
|
||||
const normalizedEmail = String(email).trim().toLowerCase();
|
||||
|
||||
// Create the auth user, auto-confirmed. If the email already exists,
|
||||
// update the existing user's password and reuse their account so the
|
||||
// signup code can still link them to the owner/rental records.
|
||||
let userId: string | null = null;
|
||||
const { data: created, error: createErr } = await admin.auth.admin.createUser({
|
||||
email: normalizedEmail,
|
||||
password: String(password),
|
||||
email_confirm: true,
|
||||
user_metadata: { full_name: full_name || null },
|
||||
});
|
||||
|
||||
if (created?.user) {
|
||||
userId = created.user.id;
|
||||
} else {
|
||||
const msg = (createErr?.message || "").toLowerCase();
|
||||
const code = (createErr as any)?.code || (createErr as any)?.error_code;
|
||||
const isDuplicate =
|
||||
code === "email_exists" ||
|
||||
msg.includes("already been registered") ||
|
||||
msg.includes("already registered") ||
|
||||
msg.includes("already exists");
|
||||
|
||||
if (!isDuplicate) {
|
||||
return json({ error: createErr?.message || "Failed to create account" }, 400);
|
||||
}
|
||||
|
||||
// Find the existing user by email and reset their password so they can sign in
|
||||
let existingId: string | null = null;
|
||||
let page = 1;
|
||||
while (page <= 20 && !existingId) {
|
||||
const { data: list, error: listErr } = await admin.auth.admin.listUsers({ page, perPage: 200 });
|
||||
if (listErr) break;
|
||||
const match = list?.users?.find((u) => (u.email || "").toLowerCase() === normalizedEmail);
|
||||
if (match) existingId = match.id;
|
||||
if (!list?.users || list.users.length < 200) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
if (!existingId) {
|
||||
return json({ error: "An account with this email already exists. Please sign in instead." }, 409);
|
||||
}
|
||||
|
||||
const { error: updErr } = await admin.auth.admin.updateUserById(existingId, {
|
||||
password: String(password),
|
||||
email_confirm: true,
|
||||
user_metadata: { full_name: full_name || null },
|
||||
});
|
||||
if (updErr) return json({ error: updErr.message }, 400);
|
||||
userId = existingId;
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return json({ error: "Failed to resolve user account" }, 500);
|
||||
}
|
||||
|
||||
// Replace default 'homeowner' role from handle_new_user trigger if a different role was assigned
|
||||
if (codeRow.role && codeRow.role !== "homeowner") {
|
||||
await admin.from("user_roles").delete().eq("user_id", userId).eq("role", "homeowner");
|
||||
await admin.from("user_roles").insert({ user_id: userId, role: codeRow.role });
|
||||
}
|
||||
|
||||
// Optional owner pin: link the new auth user to the owner record
|
||||
if (codeRow.owner_id) {
|
||||
await admin
|
||||
.from("owners")
|
||||
.update({ user_id: userId })
|
||||
.eq("id", codeRow.owner_id);
|
||||
}
|
||||
|
||||
// Link RV/Boat lot rentals so the renter/owner portal can find them.
|
||||
// Strategy:
|
||||
// 1. If the code pinned a specific rental_id, link that one.
|
||||
// 2. Otherwise, backfill any active rentals that match the pinned owner_id
|
||||
// or whose renter_email matches the new account.
|
||||
const isRvRole = codeRow.role === "rv_boat_lot" || codeRow.role === "rv_renter" || codeRow.role === "rv_owner";
|
||||
if (isRvRole) {
|
||||
const rentalUpdate: Record<string, unknown> = { user_id: userId };
|
||||
if (codeRow.role === "rv_owner") rentalUpdate.is_owner = true;
|
||||
|
||||
if (codeRow.rental_id) {
|
||||
await admin
|
||||
.from("rv_boat_lot_rentals")
|
||||
.update(rentalUpdate)
|
||||
.eq("id", codeRow.rental_id);
|
||||
} else {
|
||||
// Match by owner_id (if pinned) OR renter_email
|
||||
const orParts: string[] = [];
|
||||
if (codeRow.owner_id) orParts.push(`owner_id.eq.${codeRow.owner_id}`);
|
||||
orParts.push(`renter_email.eq.${normalizedEmail}`);
|
||||
await admin
|
||||
.from("rv_boat_lot_rentals")
|
||||
.update(rentalUpdate)
|
||||
.eq("status", "active")
|
||||
.or(orParts.join(","));
|
||||
}
|
||||
}
|
||||
|
||||
// Mark code redeemed
|
||||
await admin
|
||||
.from("signup_codes")
|
||||
.update({
|
||||
redeemed_at: new Date().toISOString(),
|
||||
redeemed_by_user_id: userId,
|
||||
redeemed_email: normalizedEmail,
|
||||
})
|
||||
.eq("id", codeRow.id);
|
||||
|
||||
return json({ success: true, role: codeRow.role });
|
||||
} catch (e) {
|
||||
return json({ error: (e as Error).message || "Unexpected error" }, 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||
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, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
const fmtMoney = (n: number) =>
|
||||
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(Number(n || 0));
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const authHeader = req.headers.get("Authorization") || "";
|
||||
|
||||
const admin = createClient(supabaseUrl, serviceKey);
|
||||
const userClient = createClient(supabaseUrl, Deno.env.get("SUPABASE_ANON_KEY")!, {
|
||||
global: { headers: { Authorization: authHeader } },
|
||||
});
|
||||
|
||||
const { data: userData } = await userClient.auth.getUser();
|
||||
const user = userData?.user;
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const { association_id, bill_ids, base_url } = body || {};
|
||||
if (!association_id || !base_url) {
|
||||
return new Response(JSON.stringify({ error: "association_id and base_url are required" }), {
|
||||
status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const { data: roles } = await admin.from("user_roles").select("role").eq("user_id", user.id);
|
||||
const isStaff = (roles || []).some((r: any) => r.role === "admin" || r.role === "manager");
|
||||
if (!isStaff) {
|
||||
return new Response(JSON.stringify({ error: "Forbidden — staff only" }), {
|
||||
status: 403, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Pending bills for this association
|
||||
let billsQ = admin
|
||||
.from("bills")
|
||||
.select("id, vendor_id, invoice_number, bill_date, due_date, amount, description, status, association_id, vendors(name), associations(name)")
|
||||
.eq("association_id", association_id)
|
||||
.eq("status", "pending");
|
||||
if (Array.isArray(bill_ids) && bill_ids.length > 0) billsQ = billsQ.in("id", bill_ids);
|
||||
const { data: bills, error: bErr } = await billsQ;
|
||||
if (bErr) throw bErr;
|
||||
if (!bills || bills.length === 0) {
|
||||
return new Response(JSON.stringify({ error: "No pending bills to send" }), {
|
||||
status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const { data: members } = await admin
|
||||
.from("board_members")
|
||||
.select("id, member_name, member_email, approval_authority")
|
||||
.eq("association_id", association_id)
|
||||
.eq("approval_authority", true);
|
||||
|
||||
const eligible = (members || []).filter(
|
||||
(m: any) => m.member_email && /\S+@\S+\.\S+/.test(m.member_email)
|
||||
);
|
||||
if (eligible.length === 0) {
|
||||
return new Response(JSON.stringify({ error: "No board members with email and approval authority" }), {
|
||||
status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const sent: string[] = [];
|
||||
const failed: { email: string; bill_id: string; reason: string }[] = [];
|
||||
|
||||
for (const bill of bills) {
|
||||
for (const m of eligible) {
|
||||
// Upsert token
|
||||
let token: string | null = null;
|
||||
const { data: existing } = await admin
|
||||
.from("bill_approval_email_tokens")
|
||||
.select("token, acted_at")
|
||||
.eq("bill_id", bill.id)
|
||||
.eq("board_member_id", m.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (existing?.token) {
|
||||
token = existing.token;
|
||||
await admin.from("bill_approval_email_tokens").update({
|
||||
email: m.member_email, member_name: m.member_name, sent_at: new Date().toISOString(),
|
||||
}).eq("bill_id", bill.id).eq("board_member_id", m.id);
|
||||
} else {
|
||||
const { data: inserted, error: insErr } = await admin
|
||||
.from("bill_approval_email_tokens")
|
||||
.insert({
|
||||
bill_id: bill.id, board_member_id: m.id,
|
||||
email: m.member_email, member_name: m.member_name,
|
||||
sent_at: new Date().toISOString(),
|
||||
})
|
||||
.select("token")
|
||||
.single();
|
||||
if (insErr || !inserted) {
|
||||
failed.push({ email: m.member_email, bill_id: bill.id, reason: insErr?.message || "token error" });
|
||||
continue;
|
||||
}
|
||||
token = inserted.token;
|
||||
}
|
||||
|
||||
const baseUrl = String(base_url).replace(/\/$/, "");
|
||||
const reviewLink = `${baseUrl}/bill-approve/${bill.id}?token=${token}`;
|
||||
const approveLink = `${reviewLink}&action=approve`;
|
||||
const denyLink = `${reviewLink}&action=deny`;
|
||||
|
||||
const { error: sendErr } = await admin.functions.invoke("send-transactional-email", {
|
||||
body: {
|
||||
templateName: "bill-approval-vote-invite",
|
||||
recipientEmail: m.member_email,
|
||||
idempotencyKey: `bill-approval-${bill.id}-${m.id}`,
|
||||
templateData: {
|
||||
memberName: m.member_name || "Board Member",
|
||||
associationName: (bill as any).associations?.name || "",
|
||||
vendorName: (bill as any).vendors?.name || "",
|
||||
invoiceNumber: bill.invoice_number || "",
|
||||
amount: fmtMoney(bill.amount),
|
||||
billDate: bill.bill_date || "",
|
||||
dueDate: bill.due_date || "",
|
||||
description: bill.description || "",
|
||||
approveLink, denyLink, reviewLink,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (sendErr) failed.push({ email: m.member_email, bill_id: bill.id, reason: sendErr.message || "send failed" });
|
||||
else sent.push(m.member_email);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
sent: sent.length, failed: failed.length, bills: bills.length, members: eligible.length, details: { failed },
|
||||
}), { headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
} catch (err: any) {
|
||||
console.error("send-bill-approval-invites error:", err);
|
||||
return new Response(JSON.stringify({ error: err.message || String(err) }), {
|
||||
status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||
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, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const authHeader = req.headers.get("Authorization") || "";
|
||||
|
||||
const admin = createClient(supabaseUrl, serviceKey);
|
||||
// user-scoped client for verifying the caller
|
||||
const userClient = createClient(supabaseUrl, Deno.env.get("SUPABASE_ANON_KEY")!, {
|
||||
global: { headers: { Authorization: authHeader } },
|
||||
});
|
||||
|
||||
const { data: userData } = await userClient.auth.getUser();
|
||||
const user = userData?.user;
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { board_vote_id, base_url, sender_id } = body || {};
|
||||
if (!board_vote_id || !base_url) {
|
||||
return new Response(JSON.stringify({ error: "board_vote_id and base_url are required" }), {
|
||||
status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Verify caller is staff
|
||||
const { data: roles } = await admin
|
||||
.from("user_roles").select("role").eq("user_id", user.id);
|
||||
const isStaff = (roles || []).some((r: any) => r.role === "admin" || r.role === "manager");
|
||||
if (!isStaff) {
|
||||
return new Response(JSON.stringify({ error: "Forbidden — staff only" }), {
|
||||
status: 403, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch vote
|
||||
const { data: vote, error: vErr } = await admin
|
||||
.from("board_votes")
|
||||
.select("id, title, description, vote_options, status, association_id, associations(name)")
|
||||
.eq("id", board_vote_id)
|
||||
.single();
|
||||
if (vErr || !vote) {
|
||||
return new Response(JSON.stringify({ error: "Board vote not found" }), {
|
||||
status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (vote.status !== "open") {
|
||||
return new Response(JSON.stringify({ error: "Vote is not open" }), {
|
||||
status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// (Sender resolution removed — emails now go through Lovable Cloud transactional email.)
|
||||
|
||||
// Fetch board members for this association
|
||||
const { data: members } = await admin
|
||||
.from("board_members")
|
||||
.select("id, member_name, member_email")
|
||||
.eq("association_id", vote.association_id);
|
||||
|
||||
const eligible = (members || []).filter((m: any) => m.member_email && /\S+@\S+\.\S+/.test(m.member_email));
|
||||
if (eligible.length === 0) {
|
||||
return new Response(JSON.stringify({ error: "No board members with email addresses" }), {
|
||||
status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const sent: string[] = [];
|
||||
const failed: { email: string; reason: string }[] = [];
|
||||
const assocName = (vote as any).associations?.name || "Your Association";
|
||||
|
||||
for (const member of eligible) {
|
||||
// Upsert token per (vote, member)
|
||||
let token: string | null = null;
|
||||
const { data: existing } = await admin
|
||||
.from("board_vote_email_tokens")
|
||||
.select("token")
|
||||
.eq("board_vote_id", board_vote_id)
|
||||
.eq("board_member_id", member.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (existing?.token) {
|
||||
token = existing.token;
|
||||
await admin.from("board_vote_email_tokens").update({
|
||||
email: member.member_email,
|
||||
member_name: member.member_name,
|
||||
sent_at: new Date().toISOString(),
|
||||
}).eq("board_vote_id", board_vote_id).eq("board_member_id", member.id);
|
||||
} else {
|
||||
const { data: inserted, error: insErr } = await admin
|
||||
.from("board_vote_email_tokens")
|
||||
.insert({
|
||||
board_vote_id,
|
||||
board_member_id: member.id,
|
||||
email: member.member_email,
|
||||
member_name: member.member_name,
|
||||
sent_at: new Date().toISOString(),
|
||||
})
|
||||
.select("token")
|
||||
.single();
|
||||
if (insErr || !inserted) {
|
||||
failed.push({ email: member.member_email, reason: insErr?.message || "Could not create token" });
|
||||
continue;
|
||||
}
|
||||
token = inserted.token;
|
||||
}
|
||||
|
||||
const link = `${base_url.replace(/\/$/, "")}/board-vote/${board_vote_id}?token=${token}`;
|
||||
const { error: sendErr } = await admin.functions.invoke("send-transactional-email", {
|
||||
body: {
|
||||
templateName: "board-vote-invite",
|
||||
recipientEmail: member.member_email,
|
||||
idempotencyKey: `board-vote-${board_vote_id}-${member.id}`,
|
||||
templateData: {
|
||||
memberName: member.member_name || "Board Member",
|
||||
voteTitle: vote.title,
|
||||
voteDescription: vote.description || "",
|
||||
associationName: assocName,
|
||||
link,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (sendErr) {
|
||||
failed.push({ email: member.member_email, reason: sendErr.message || "Send failed" });
|
||||
} else {
|
||||
sent.push(member.member_email);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ sent: sent.length, failed: failed.length, details: { sent, failed } }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("send-board-vote-invites error:", err);
|
||||
return new Response(JSON.stringify({ error: err.message || String(err) }), {
|
||||
status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return String(s ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.3";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||
try {
|
||||
const supabase = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);
|
||||
const { booking_id, amount_cents } = await req.json();
|
||||
if (!booking_id || !amount_cents || amount_cents <= 0) {
|
||||
return new Response(JSON.stringify({ error: "booking_id and positive amount_cents required" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
const { data: booking, error: bErr } = await supabase
|
||||
.from("amenity_bookings")
|
||||
.select("*, amenities(name), associations(name)")
|
||||
.eq("id", booking_id).single();
|
||||
if (bErr || !booking) return new Response(JSON.stringify({ error: "Booking not found" }), { status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
if (!booking.guest_email) return new Response(JSON.stringify({ error: "Booking has no contact email" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
|
||||
// Find Stripe mapping
|
||||
let { data: mapping } = await supabase.from("stripe_account_mappings").select("*").eq("association_id", booking.association_id).eq("is_active", true).maybeSingle();
|
||||
if (!mapping) {
|
||||
const { data: company } = await supabase.from("stripe_account_mappings").select("*").is("association_id", null).eq("is_active", true).maybeSingle();
|
||||
mapping = company;
|
||||
}
|
||||
if (!mapping?.stripe_secret_key) return new Response(JSON.stringify({ error: "No Stripe gateway configured" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
|
||||
// Gross-up to pass processing fees to payer
|
||||
const totalCents = Math.round((amount_cents + 30) / (1 - 0.029));
|
||||
const origin = req.headers.get("origin") || "https://avria.cloud";
|
||||
const params = new URLSearchParams();
|
||||
params.append("mode", "payment");
|
||||
params.append("line_items[0][price_data][currency]", "usd");
|
||||
params.append("line_items[0][price_data][unit_amount]", String(totalCents));
|
||||
params.append("line_items[0][price_data][product_data][name]", `Booking: ${booking.title || booking.amenities?.name || "Amenity"}`);
|
||||
params.append("line_items[0][quantity]", "1");
|
||||
params.append("customer_email", booking.guest_email);
|
||||
params.append("success_url", `${origin}/booking/${booking.id}?paid=1`);
|
||||
params.append("cancel_url", `${origin}/booking/${booking.id}`);
|
||||
params.append("metadata[booking_id]", booking.id);
|
||||
params.append("metadata[type]", "amenity_booking_link");
|
||||
|
||||
const stripeResp = await fetch("https://api.stripe.com/v1/checkout/sessions", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${mapping.stripe_secret_key}`, "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: params.toString(),
|
||||
});
|
||||
const session = await stripeResp.json();
|
||||
if (!stripeResp.ok) return new Response(JSON.stringify({ error: session.error?.message || "Stripe error" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
|
||||
await supabase.from("amenity_bookings").update({
|
||||
payment_status: "link_sent",
|
||||
payment_amount_cents: amount_cents,
|
||||
payment_link_url: session.url,
|
||||
}).eq("id", booking.id);
|
||||
|
||||
// Send email with link via existing transactional template
|
||||
try {
|
||||
await supabase.functions.invoke("send-transactional-email", {
|
||||
body: {
|
||||
templateName: "amenity-booking-confirmation",
|
||||
recipientEmail: booking.guest_email,
|
||||
idempotencyKey: `booking-payment-link-${booking.id}-${Date.now()}`,
|
||||
templateData: {
|
||||
guestName: booking.guest_name,
|
||||
amenityName: booking.amenities?.name || "Amenity",
|
||||
associationName: booking.associations?.name || "",
|
||||
bookingDate: booking.booking_date,
|
||||
startTime: booking.start_time || "",
|
||||
status: "payment_due",
|
||||
confirmationLink: session.url,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (e) { console.error("email send failed", e); }
|
||||
|
||||
return new Response(JSON.stringify({ success: true, checkout_url: session.url }), { headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
} catch (e: any) {
|
||||
return new Response(JSON.stringify({ error: e.message || "Internal error" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||
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",
|
||||
};
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
|
||||
const { election_id, base_url } = await req.json();
|
||||
|
||||
if (!election_id || !base_url) {
|
||||
return new Response(JSON.stringify({ error: "election_id and base_url are required" }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch election
|
||||
const { data: election, error: elErr } = await supabase
|
||||
.from("elections")
|
||||
.select("*, association:association_id(id, name)")
|
||||
.eq("id", election_id)
|
||||
.single();
|
||||
|
||||
if (elErr || !election) {
|
||||
return new Response(JSON.stringify({ error: "Election not found" }), {
|
||||
status: 404,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch eligible voters who have consent and a vote_token
|
||||
const { data: voters, error: vErr } = await supabase
|
||||
.from("election_eligible_voters")
|
||||
.select("*, owner:owner_id(id, first_name, last_name, email)")
|
||||
.eq("election_id", election_id)
|
||||
.eq("has_consent", true);
|
||||
|
||||
if (vErr) throw vErr;
|
||||
|
||||
const sent: string[] = [];
|
||||
const failed: string[] = [];
|
||||
|
||||
for (const voter of voters || []) {
|
||||
const email = voter.owner?.email;
|
||||
if (!email || !voter.vote_token) {
|
||||
failed.push(voter.owner?.email || "unknown");
|
||||
continue;
|
||||
}
|
||||
|
||||
const votingUrl = `${base_url}/vote/${election_id}?token=${voter.vote_token}`;
|
||||
const ownerName = `${voter.owner?.first_name || ""} ${voter.owner?.last_name || ""}`.trim() || "Owner";
|
||||
|
||||
// Use Supabase's built-in email (or you can integrate with any SMTP)
|
||||
// For now, we'll use the auth admin to send a simple email
|
||||
try {
|
||||
const deadline = election.voting_end
|
||||
? new Date(election.voting_end).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })
|
||||
: "";
|
||||
const { error: invokeErr } = await supabase.functions.invoke("send-transactional-email", {
|
||||
body: {
|
||||
templateName: "election-invite",
|
||||
recipientEmail: email,
|
||||
idempotencyKey: `election-${election_id}-${voter.owner?.id || email}`,
|
||||
templateData: {
|
||||
ownerName,
|
||||
electionTitle: election.title,
|
||||
associationName: election.association?.name || "",
|
||||
deadline,
|
||||
votingUrl,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (invokeErr) failed.push(email);
|
||||
else sent.push(email);
|
||||
} catch {
|
||||
failed.push(email);
|
||||
}
|
||||
|
||||
// Log the send
|
||||
await supabase.from("election_audit_log").insert({
|
||||
election_id,
|
||||
voter_description: `Invite sent to ${ownerName} (${email})`,
|
||||
action: "invite_sent",
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ sent: sent.length, failed: failed.length, details: { sent, failed } }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error sending election invites:", err);
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,876 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
type SenderConfig = {
|
||||
host: string;
|
||||
port?: number;
|
||||
username: string;
|
||||
password: string;
|
||||
use_tls?: boolean;
|
||||
use_ssl?: boolean;
|
||||
from: string;
|
||||
fromEmail?: string;
|
||||
fromName?: string;
|
||||
envelopeFrom?: string;
|
||||
signature_html?: string;
|
||||
};
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
const FALLBACK_PUBLIC_SUPABASE_URL = "https://yqdefzjapnzabowsgoyd.supabase.co";
|
||||
|
||||
function getPublicFunctionBaseUrl() {
|
||||
const explicitUrl = Deno.env.get("PUBLIC_SUPABASE_URL") || Deno.env.get("PUBLIC_FUNCTION_BASE_URL") || Deno.env.get("VITE_SUPABASE_URL");
|
||||
if (explicitUrl) return explicitUrl.replace(/\/$/, "");
|
||||
|
||||
const internalUrl = (Deno.env.get("SUPABASE_URL") || "").replace(/\/$/, "");
|
||||
if (/^https:\/\/[^/]+\.supabase\.co$/i.test(internalUrl)) return internalUrl;
|
||||
|
||||
const projectRef = Deno.env.get("SUPABASE_PROJECT_REF") || Deno.env.get("SB_PROJECT_REF");
|
||||
if (projectRef) return `https://${projectRef}.supabase.co`;
|
||||
|
||||
return FALLBACK_PUBLIC_SUPABASE_URL;
|
||||
}
|
||||
|
||||
const ALLOWED_ROLES = ["admin", "manager", "staff", "employee", "board_member", "arc_member", "fining_member"] as const;
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
async function getAuthorizedCaller(req: Request) {
|
||||
const authHeader = req.headers.get("authorization") || "";
|
||||
if (!authHeader.startsWith("Bearer ")) {
|
||||
return { error: jsonResponse({ success: false, error: "Unauthorized" }, 401) };
|
||||
}
|
||||
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
|
||||
const callerClient = createClient(supabaseUrl, anonKey, {
|
||||
global: { headers: { Authorization: authHeader } },
|
||||
});
|
||||
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
const { data: claimsData, error: claimsError } = await callerClient.auth.getClaims(token);
|
||||
const callerId = claimsData?.claims?.sub;
|
||||
|
||||
if (claimsError || !callerId) {
|
||||
console.error("[send-smtp-email] Auth failed - claimsError:", claimsError?.message, "callerId:", callerId);
|
||||
return { error: jsonResponse({ success: false, error: "Unauthorized" }, 401) };
|
||||
}
|
||||
|
||||
console.log("[send-smtp-email] Authenticated user:", callerId);
|
||||
|
||||
for (const role of ALLOWED_ROLES) {
|
||||
const { data: hasRole } = await callerClient.rpc("has_role", {
|
||||
_user_id: callerId,
|
||||
_role: role,
|
||||
});
|
||||
|
||||
if (hasRole) {
|
||||
console.log("[send-smtp-email] User has role:", role);
|
||||
return { callerId, authHeader };
|
||||
}
|
||||
}
|
||||
|
||||
console.error("[send-smtp-email] No matching role found for user:", callerId);
|
||||
return { error: jsonResponse({ success: false, error: "Insufficient permissions" }, 403) };
|
||||
}
|
||||
|
||||
async function getAuthenticatedCaller(req: Request) {
|
||||
const authHeader = req.headers.get("authorization") || "";
|
||||
if (!authHeader.startsWith("Bearer ")) {
|
||||
return { error: jsonResponse({ success: false, error: "Unauthorized" }, 401) };
|
||||
}
|
||||
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
|
||||
const callerClient = createClient(supabaseUrl, anonKey, {
|
||||
global: { headers: { Authorization: authHeader } },
|
||||
});
|
||||
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
const { data: claimsData, error: claimsError } = await callerClient.auth.getClaims(token);
|
||||
const callerId = claimsData?.claims?.sub;
|
||||
|
||||
if (claimsError || !callerId) {
|
||||
return { error: jsonResponse({ success: false, error: "Unauthorized" }, 401) };
|
||||
}
|
||||
|
||||
return { callerId };
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function mapSenderRowToConfig(sender: any): SenderConfig {
|
||||
const port = Number(sender.smtp_port ?? 587);
|
||||
const isImplicitSslPort = port === 465;
|
||||
const isStartTlsPort = port === 587;
|
||||
const envelopeFrom = sender.smtp_username || sender.email_address;
|
||||
|
||||
return {
|
||||
host: sender.smtp_host,
|
||||
port,
|
||||
username: sender.smtp_username,
|
||||
password: sender.smtp_password,
|
||||
use_ssl: isImplicitSslPort ? true : sender.use_ssl ?? false,
|
||||
use_tls: isImplicitSslPort ? false : sender.use_tls ?? isStartTlsPort,
|
||||
from: sender.sender_name
|
||||
? `${sender.sender_name} <${sender.email_address}>`
|
||||
: sender.email_address,
|
||||
fromEmail: sender.email_address,
|
||||
fromName: sender.sender_name,
|
||||
envelopeFrom,
|
||||
signature_html: sender.signature_html || "",
|
||||
};
|
||||
}
|
||||
|
||||
function ensureHtmlDocument(htmlContent: string) {
|
||||
const trimmed = String(htmlContent ?? "").trim();
|
||||
|
||||
if (!trimmed) return "<!DOCTYPE html><html><body></body></html>";
|
||||
if (/<!doctype html/i.test(trimmed) || /<html[\s>]/i.test(trimmed)) return trimmed;
|
||||
|
||||
return `<!DOCTYPE html><html><body>${trimmed}</body></html>`;
|
||||
}
|
||||
|
||||
function toPlainText(htmlContent: string) {
|
||||
return htmlContent
|
||||
.replace(/<\s*br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/(p|div|h1|h2|h3|h4|h5|h6|li|tr|section)\s*>/gi, "\n")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function toBase64Utf8(value: string) {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function chunkBase64(value: string, lineLength = 76) {
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < value.length; i += lineLength) {
|
||||
lines.push(value.slice(i, i + lineLength));
|
||||
}
|
||||
return lines.join("\r\n");
|
||||
}
|
||||
|
||||
function sanitizeHeaderValue(value: string) {
|
||||
return String(value ?? "")
|
||||
.replace(/[\r\n]+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractEmailAddress(value: string) {
|
||||
const sanitized = sanitizeHeaderValue(value);
|
||||
const bracketMatch = sanitized.match(/<([^>]+)>/);
|
||||
if (bracketMatch?.[1]) return bracketMatch[1].trim();
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function extractDisplayName(value: string) {
|
||||
const sanitized = sanitizeHeaderValue(value);
|
||||
const bracketIndex = sanitized.indexOf("<");
|
||||
if (bracketIndex <= 0) return "";
|
||||
return sanitized.slice(0, bracketIndex).trim().replace(/^"|"$/g, "");
|
||||
}
|
||||
|
||||
function formatAddressHeader(email: string, displayName?: string) {
|
||||
const safeEmail = sanitizeHeaderValue(email);
|
||||
const safeName = sanitizeHeaderValue(displayName || "");
|
||||
|
||||
if (!safeName) return safeEmail;
|
||||
|
||||
const escapedName = safeName.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
const needsQuotes = /[",;<>@\[\]\(\):]/.test(escapedName);
|
||||
return needsQuotes
|
||||
? `"${escapedName}" <${safeEmail}>`
|
||||
: `${escapedName} <${safeEmail}>`;
|
||||
}
|
||||
|
||||
function normalizeRecipients(recipients: string[]) {
|
||||
return recipients
|
||||
.map((recipient) => sanitizeHeaderValue(recipient))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function dotStuffSmtpData(content: string) {
|
||||
const normalized = content.replace(/\r?\n/g, "\r\n");
|
||||
return normalized.replace(/(^|\r\n)\./g, "$1..");
|
||||
}
|
||||
|
||||
function buildSenderKey(sender: any) {
|
||||
return `${String(sender.email_address ?? "").trim().toLowerCase()}::${String(sender.smtp_username ?? "").trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
function sortSendersForDisplay(a: any, b: any) {
|
||||
const defaultDiff = Number(Boolean(b.is_default)) - Number(Boolean(a.is_default));
|
||||
if (defaultDiff !== 0) return defaultDiff;
|
||||
|
||||
const verifiedDiff = Number(Boolean(b.verified)) - Number(Boolean(a.verified));
|
||||
if (verifiedDiff !== 0) return verifiedDiff;
|
||||
|
||||
return new Date(b.updated_at ?? 0).getTime() - new Date(a.updated_at ?? 0).getTime();
|
||||
}
|
||||
|
||||
const senderSelectFields = "id, sender_name, email_address, smtp_host, smtp_port, smtp_username, smtp_password, use_tls, use_ssl, is_active, verified, is_default, updated_at, signature_html";
|
||||
|
||||
async function resolveLatestSenderVariant(adminClient: any, sender: any) {
|
||||
const normalizedEmail = String(sender?.email_address ?? "").trim().toLowerCase();
|
||||
const normalizedUsername = String(sender?.smtp_username ?? "").trim().toLowerCase();
|
||||
|
||||
if (!normalizedEmail || !normalizedUsername) {
|
||||
return sender;
|
||||
}
|
||||
|
||||
const { data: matchingSenders, error } = await adminClient
|
||||
.from("email_senders")
|
||||
.select(senderSelectFields)
|
||||
.eq("is_active", true)
|
||||
.eq("email_address", normalizedEmail)
|
||||
.eq("smtp_username", normalizedUsername)
|
||||
.order("updated_at", { ascending: false });
|
||||
|
||||
if (error || !matchingSenders?.length) {
|
||||
return sender;
|
||||
}
|
||||
|
||||
const [latestSender] = matchingSenders.sort((a: any, b: any) => new Date(b.updated_at ?? 0).getTime() - new Date(a.updated_at ?? 0).getTime());
|
||||
|
||||
if (latestSender?.id && latestSender.id !== sender.id) {
|
||||
console.log("[send-smtp-email] Replaced stale sender", sender.id, "with latest sender", latestSender.id);
|
||||
}
|
||||
|
||||
return latestSender ?? sender;
|
||||
}
|
||||
|
||||
async function resolveSender(payload: any, adminClient: any, req: Request) {
|
||||
if (payload.sender_id) {
|
||||
const auth = await getAuthorizedCaller(req);
|
||||
if (auth.error) return auth;
|
||||
|
||||
const { data: sender, error } = await adminClient
|
||||
.from("email_senders")
|
||||
.select(senderSelectFields)
|
||||
.eq("id", payload.sender_id)
|
||||
.maybeSingle();
|
||||
|
||||
if (error || !sender) {
|
||||
return { error: jsonResponse({ success: false, error: "Sender not found or inactive. Please update your sender in Email Settings." }, 404) };
|
||||
}
|
||||
|
||||
const freshestSender = await resolveLatestSenderVariant(adminClient, sender);
|
||||
|
||||
if (!freshestSender?.is_active) {
|
||||
return { error: jsonResponse({ success: false, error: "Sender not found or inactive. Please update your sender in Email Settings." }, 404) };
|
||||
}
|
||||
|
||||
return { sender: mapSenderRowToConfig(freshestSender), callerId: auth.callerId };
|
||||
}
|
||||
|
||||
if (payload.sender) {
|
||||
return { sender: payload.sender, callerId: payload.fallback_user_id ?? null };
|
||||
}
|
||||
|
||||
return { error: jsonResponse({ success: false, error: "Missing sender configuration" }, 400) };
|
||||
}
|
||||
|
||||
const SMTP_CONNECT_TIMEOUT_MS = 20000;
|
||||
const SMTP_COMMAND_TIMEOUT_MS = 30000;
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
|
||||
let timeoutId: number | undefined;
|
||||
|
||||
try {
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
|
||||
promise.then(resolve).catch(reject);
|
||||
});
|
||||
} finally {
|
||||
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendViaSMTP(
|
||||
sender: SenderConfig,
|
||||
toList: string[],
|
||||
ccList: string[],
|
||||
bccList: string[],
|
||||
subject: string,
|
||||
htmlContent: string,
|
||||
attachments: any[],
|
||||
debug: boolean
|
||||
): Promise<{ success: boolean; error?: string; diagnosticLogs: string[]; smtpResponse?: string }> {
|
||||
const {
|
||||
host,
|
||||
port = 587,
|
||||
username,
|
||||
password,
|
||||
use_tls = true,
|
||||
use_ssl = false,
|
||||
from,
|
||||
fromEmail,
|
||||
fromName,
|
||||
envelopeFrom,
|
||||
} = sender;
|
||||
const diagnosticLogs: string[] = [];
|
||||
|
||||
if (!host || !username || !password || !from) {
|
||||
return { success: false, error: "Incomplete sender configuration (missing host, username, password, or from address)", diagnosticLogs };
|
||||
}
|
||||
|
||||
const boundary = `----=_Part_${crypto.randomUUID().replace(/-/g, "")}`;
|
||||
const alternativeBoundary = `----=_Alt_${crypto.randomUUID().replace(/-/g, "")}`;
|
||||
let mimeMessage = "";
|
||||
const hasAttachments = Array.isArray(attachments) && attachments.length > 0;
|
||||
const resolvedFromEmail = sanitizeHeaderValue(
|
||||
fromEmail || envelopeFrom || username || extractEmailAddress(from)
|
||||
);
|
||||
const resolvedFromName = sanitizeHeaderValue(fromName || extractDisplayName(from));
|
||||
const fromHeader = formatAddressHeader(resolvedFromEmail, resolvedFromName);
|
||||
const safeHtmlContent = ensureHtmlDocument(htmlContent);
|
||||
const plainTextContent = toPlainText(safeHtmlContent) || subject;
|
||||
const messageIdDomain = resolvedFromEmail.includes("@") ? resolvedFromEmail.split("@")[1] : host;
|
||||
const messageId = `<${crypto.randomUUID()}@${messageIdDomain}>`;
|
||||
const safeSubject = sanitizeHeaderValue(subject);
|
||||
const normalizedToList = normalizeRecipients(toList);
|
||||
const normalizedCcList = normalizeRecipients(ccList);
|
||||
const normalizedBccList = normalizeRecipients(bccList);
|
||||
|
||||
const headers: string[] = [
|
||||
`From: ${fromHeader}`,
|
||||
`To: ${normalizedToList.join(", ")}`,
|
||||
`Reply-To: ${resolvedFromEmail}`,
|
||||
];
|
||||
if (normalizedCcList.length > 0) headers.push(`Cc: ${normalizedCcList.join(", ")}`);
|
||||
headers.push(`Subject: ${safeSubject}`);
|
||||
headers.push(`Date: ${new Date().toUTCString()}`);
|
||||
headers.push(`Message-ID: ${messageId}`);
|
||||
headers.push(`MIME-Version: 1.0`);
|
||||
|
||||
if (hasAttachments) {
|
||||
headers.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
|
||||
mimeMessage = headers.join("\r\n") + "\r\n\r\n";
|
||||
mimeMessage += `--${boundary}\r\n`;
|
||||
mimeMessage += `Content-Type: multipart/alternative; boundary="${alternativeBoundary}"\r\n\r\n`;
|
||||
mimeMessage += `--${alternativeBoundary}\r\n`;
|
||||
mimeMessage += `Content-Type: text/plain; charset=UTF-8\r\n`;
|
||||
mimeMessage += `Content-Transfer-Encoding: base64\r\n\r\n`;
|
||||
mimeMessage += chunkBase64(toBase64Utf8(plainTextContent)) + "\r\n\r\n";
|
||||
mimeMessage += `--${alternativeBoundary}\r\n`;
|
||||
mimeMessage += `Content-Type: text/html; charset=UTF-8\r\n`;
|
||||
mimeMessage += `Content-Transfer-Encoding: base64\r\n\r\n`;
|
||||
mimeMessage += chunkBase64(toBase64Utf8(safeHtmlContent)) + "\r\n\r\n";
|
||||
mimeMessage += `--${alternativeBoundary}--\r\n\r\n`;
|
||||
|
||||
for (const att of attachments) {
|
||||
const filename = att.filename || att.name || "attachment";
|
||||
let attachmentContent = att.content || "";
|
||||
|
||||
if (att.path && !att.content) {
|
||||
try {
|
||||
const resp = await fetch(att.path);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const arrBuf = await resp.arrayBuffer();
|
||||
const bytes = new Uint8Array(arrBuf);
|
||||
// Memory-efficient base64 encoding: process in chunks to avoid
|
||||
// building a huge intermediate binary string (which doubles memory).
|
||||
const CHUNK = 0x8000; // 32KB
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||||
const slice = bytes.subarray(i, Math.min(i + CHUNK, bytes.length));
|
||||
// String.fromCharCode.apply is fast and avoids per-char concat.
|
||||
parts.push(String.fromCharCode.apply(null, Array.from(slice)));
|
||||
}
|
||||
attachmentContent = btoa(parts.join(""));
|
||||
if (debug) {
|
||||
diagnosticLogs.push(`Attachment ${filename}: ${bytes.length} bytes -> ${attachmentContent.length} base64 chars`);
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
console.error(`Failed to fetch attachment ${filename}:`, e);
|
||||
diagnosticLogs.push(`WARN: Failed to fetch attachment ${filename}: ${message}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
mimeMessage += `--${boundary}\r\n`;
|
||||
mimeMessage += `Content-Type: application/octet-stream; name="${filename}"\r\n`;
|
||||
mimeMessage += `Content-Disposition: attachment; filename="${filename}"\r\n`;
|
||||
mimeMessage += `Content-Transfer-Encoding: base64\r\n\r\n`;
|
||||
|
||||
// Build chunked base64 in an array, then join once (avoids O(n²) string concat)
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < attachmentContent.length; i += 76) {
|
||||
lines.push(attachmentContent.substring(i, i + 76));
|
||||
}
|
||||
mimeMessage += lines.join("\r\n") + "\r\n\r\n";
|
||||
}
|
||||
mimeMessage += `--${boundary}--\r\n`;
|
||||
} else {
|
||||
headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
|
||||
mimeMessage = headers.join("\r\n") + "\r\n\r\n";
|
||||
mimeMessage += `--${boundary}\r\n`;
|
||||
mimeMessage += `Content-Type: text/plain; charset=UTF-8\r\n`;
|
||||
mimeMessage += `Content-Transfer-Encoding: base64\r\n\r\n`;
|
||||
mimeMessage += chunkBase64(toBase64Utf8(plainTextContent)) + "\r\n\r\n";
|
||||
mimeMessage += `--${boundary}\r\n`;
|
||||
mimeMessage += `Content-Type: text/html; charset=UTF-8\r\n`;
|
||||
mimeMessage += `Content-Transfer-Encoding: base64\r\n\r\n`;
|
||||
mimeMessage += chunkBase64(toBase64Utf8(safeHtmlContent)) + "\r\n\r\n";
|
||||
mimeMessage += `--${boundary}--\r\n`;
|
||||
}
|
||||
|
||||
let conn: Deno.Conn | Deno.TlsConn;
|
||||
try {
|
||||
if (debug) {
|
||||
diagnosticLogs.push(`Connecting to ${host}:${port} using ${use_ssl ? "implicit SSL/TLS" : "plain SMTP"}`);
|
||||
}
|
||||
|
||||
conn = use_ssl
|
||||
? await withTimeout(
|
||||
Deno.connectTls({ hostname: host, port: Number(port) }),
|
||||
SMTP_CONNECT_TIMEOUT_MS,
|
||||
`Timed out connecting securely to SMTP server ${host}:${port}`
|
||||
)
|
||||
: await withTimeout(
|
||||
Deno.connect({ hostname: host, port: Number(port) }),
|
||||
SMTP_CONNECT_TIMEOUT_MS,
|
||||
`Timed out connecting to SMTP server ${host}:${port}`
|
||||
);
|
||||
} catch (connErr) {
|
||||
const message = connErr instanceof Error ? connErr.message : String(connErr);
|
||||
return { success: false, error: `Failed to connect to SMTP server ${host}:${port} — ${message}`, diagnosticLogs };
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
let authSecretCommandsRemaining = 0;
|
||||
|
||||
const readResponse = async (c: Deno.Conn | Deno.TlsConn): Promise<string> => {
|
||||
const buf = new Uint8Array(4096);
|
||||
const n = await withTimeout(
|
||||
c.read(buf),
|
||||
SMTP_COMMAND_TIMEOUT_MS,
|
||||
`Timed out waiting for SMTP response from ${host}:${port}`
|
||||
);
|
||||
|
||||
if (n === null) {
|
||||
throw new Error("SMTP server closed the connection unexpectedly.");
|
||||
}
|
||||
|
||||
const response = decoder.decode(buf.subarray(0, n));
|
||||
if (debug) diagnosticLogs.push(`S: ${response.trim()}`);
|
||||
return response;
|
||||
};
|
||||
|
||||
const sendCommand = async (c: Deno.Conn | Deno.TlsConn, command: string): Promise<string> => {
|
||||
if (debug) {
|
||||
let logCmd = command.trim();
|
||||
|
||||
if (command.startsWith("AUTH LOGIN")) {
|
||||
logCmd = "AUTH LOGIN ***";
|
||||
authSecretCommandsRemaining = 2;
|
||||
} else if (authSecretCommandsRemaining > 0) {
|
||||
logCmd = "***";
|
||||
authSecretCommandsRemaining -= 1;
|
||||
}
|
||||
|
||||
diagnosticLogs.push(`C: ${logCmd}`);
|
||||
}
|
||||
|
||||
await withTimeout(
|
||||
c.write(encoder.encode(command + "\r\n")),
|
||||
SMTP_COMMAND_TIMEOUT_MS,
|
||||
`Timed out sending SMTP command to ${host}:${port}`
|
||||
);
|
||||
|
||||
return await readResponse(c);
|
||||
};
|
||||
|
||||
try {
|
||||
await readResponse(conn);
|
||||
const ehloResp = await sendCommand(conn, "EHLO localhost");
|
||||
|
||||
let activeConn: Deno.Conn | Deno.TlsConn = conn;
|
||||
|
||||
if (!use_ssl && use_tls && ehloResp.includes("STARTTLS")) {
|
||||
const starttlsResp = await sendCommand(conn, "STARTTLS");
|
||||
if (starttlsResp.startsWith("220")) {
|
||||
activeConn = await withTimeout(
|
||||
Deno.startTls(conn as Deno.TcpConn, { hostname: host }),
|
||||
SMTP_COMMAND_TIMEOUT_MS,
|
||||
`Timed out upgrading SMTP connection with STARTTLS for ${host}:${port}`
|
||||
);
|
||||
await sendCommand(activeConn, "EHLO localhost");
|
||||
} else {
|
||||
// STARTTLS temporarily unavailable — retry once after a short delay
|
||||
if (debug) diagnosticLogs.push(`STARTTLS failed (${starttlsResp.trim()}), retrying in 2s...`);
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
const retryResp = await sendCommand(conn, "STARTTLS");
|
||||
if (retryResp.startsWith("220")) {
|
||||
activeConn = await withTimeout(
|
||||
Deno.startTls(conn as Deno.TcpConn, { hostname: host }),
|
||||
SMTP_COMMAND_TIMEOUT_MS,
|
||||
`Timed out upgrading SMTP connection with STARTTLS for ${host}:${port}`
|
||||
);
|
||||
await sendCommand(activeConn, "EHLO localhost");
|
||||
} else {
|
||||
conn.close();
|
||||
return { success: false, error: `SMTP STARTTLS not available: ${retryResp.trim()}. Try again shortly or switch to port 465 (SSL).`, diagnosticLogs };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await sendCommand(activeConn, "AUTH LOGIN");
|
||||
await sendCommand(activeConn, btoa(username));
|
||||
const authResp = await sendCommand(activeConn, btoa(password));
|
||||
|
||||
if (!authResp.startsWith("235")) {
|
||||
activeConn.close();
|
||||
return { success: false, error: `SMTP authentication failed. Check your sender credentials for ${from}.`, diagnosticLogs };
|
||||
}
|
||||
|
||||
const mailFromResp = await sendCommand(activeConn, `MAIL FROM:<${resolvedFromEmail}>`);
|
||||
if (!mailFromResp.startsWith("250")) {
|
||||
activeConn.close();
|
||||
return { success: false, error: `SMTP MAIL FROM rejected: ${mailFromResp.trim()}`, diagnosticLogs };
|
||||
}
|
||||
|
||||
const allRecipients = [...normalizedToList, ...normalizedCcList, ...normalizedBccList];
|
||||
let acceptedRecipients = 0;
|
||||
for (const r of allRecipients) {
|
||||
const rcptResp = await sendCommand(activeConn, `RCPT TO:<${r.trim()}>`);
|
||||
if (!rcptResp.startsWith("250") && !rcptResp.startsWith("251")) {
|
||||
diagnosticLogs.push(`WARN: RCPT TO rejected for ${r}: ${rcptResp.trim()}`);
|
||||
} else {
|
||||
acceptedRecipients += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (acceptedRecipients === 0) {
|
||||
activeConn.close();
|
||||
return {
|
||||
success: false,
|
||||
error: "SMTP rejected all recipients. Check the recipient address and sender/domain alignment.",
|
||||
diagnosticLogs,
|
||||
};
|
||||
}
|
||||
|
||||
const dataResp = await sendCommand(activeConn, "DATA");
|
||||
if (!dataResp.startsWith("354")) {
|
||||
activeConn.close();
|
||||
return { success: false, error: `SMTP DATA command rejected: ${dataResp.trim()}`, diagnosticLogs };
|
||||
}
|
||||
|
||||
// Write message body in chunks to prevent SMTP server idle timeout
|
||||
const fullData = dotStuffSmtpData(mimeMessage) + "\r\n.\r\n";
|
||||
const CHUNK_SIZE = 4096;
|
||||
for (let offset = 0; offset < fullData.length; offset += CHUNK_SIZE) {
|
||||
const chunk = fullData.slice(offset, offset + CHUNK_SIZE);
|
||||
await withTimeout(
|
||||
activeConn.write(encoder.encode(chunk)),
|
||||
SMTP_COMMAND_TIMEOUT_MS,
|
||||
`Timed out sending SMTP message body chunk to ${host}:${port}`
|
||||
);
|
||||
}
|
||||
|
||||
const finalResp = await readResponse(activeConn);
|
||||
const smtpResponse = finalResp.trim();
|
||||
await sendCommand(activeConn, "QUIT");
|
||||
activeConn.close();
|
||||
|
||||
if (!finalResp.startsWith("250")) {
|
||||
return { success: false, error: `SMTP server rejected message: ${finalResp.trim()}`, diagnosticLogs };
|
||||
}
|
||||
|
||||
return { success: true, diagnosticLogs, smtpResponse };
|
||||
} catch (smtpErr) {
|
||||
try { conn.close(); } catch (_) {}
|
||||
const message = smtpErr instanceof Error ? smtpErr.message : String(smtpErr);
|
||||
return { success: false, error: `SMTP error: ${message}`, diagnosticLogs };
|
||||
}
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await req.json();
|
||||
console.log("[send-smtp-email] Request received, action:", payload.action || "send", "sender_id:", payload.sender_id || "none");
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const adminClient = createClient(supabaseUrl, serviceKey, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
});
|
||||
|
||||
// === List Senders ===
|
||||
if (payload.action === "list_senders") {
|
||||
const auth = await getAuthorizedCaller(req);
|
||||
if (auth.error) return auth.error;
|
||||
|
||||
const { data: senders, error } = await adminClient
|
||||
.from("email_senders")
|
||||
.select("id, sender_name, email_address, smtp_username, is_default, updated_at, verified")
|
||||
.eq("is_active", true)
|
||||
.order("is_default", { ascending: false })
|
||||
.order("updated_at", { ascending: false });
|
||||
|
||||
if (error) {
|
||||
return jsonResponse({ success: false, error: (error as Error).message }, 500);
|
||||
}
|
||||
|
||||
const latestSenderByKey = new Map<string, any>();
|
||||
for (const sender of (senders ?? []).sort((a, b) => new Date(b.updated_at ?? 0).getTime() - new Date(a.updated_at ?? 0).getTime())) {
|
||||
const key = buildSenderKey(sender);
|
||||
if (!latestSenderByKey.has(key)) {
|
||||
latestSenderByKey.set(key, sender);
|
||||
}
|
||||
}
|
||||
|
||||
const dedupedSenders = Array.from(latestSenderByKey.values()).sort(sortSendersForDisplay);
|
||||
|
||||
return jsonResponse({ success: true, senders: dedupedSenders });
|
||||
}
|
||||
|
||||
if (payload.action === "send_direct_message_notification") {
|
||||
const auth = await getAuthenticatedCaller(req);
|
||||
if (auth.error) return auth.error;
|
||||
|
||||
const recipientIds = Array.isArray(payload.recipient_ids)
|
||||
? payload.recipient_ids.filter((id: unknown) => typeof id === "string" && id.trim())
|
||||
: [];
|
||||
const message = String(payload.message ?? "").trim();
|
||||
const senderName = String(payload.sender_name ?? "Someone").trim() || "Someone";
|
||||
|
||||
if (recipientIds.length === 0 || !message) {
|
||||
return jsonResponse({ success: false, error: "Missing required fields: recipient_ids, message" }, 400);
|
||||
}
|
||||
|
||||
const { data: profiles, error: profileError } = await adminClient
|
||||
.from("profiles")
|
||||
.select("user_id, full_name, email, preferred_notification_email")
|
||||
.in("user_id", recipientIds);
|
||||
|
||||
if (profileError) return jsonResponse({ success: false, error: profileError.message }, 500);
|
||||
|
||||
const recipients = (profiles ?? [])
|
||||
.map((profile: any) => ({
|
||||
email: String(profile.preferred_notification_email || profile.email || "").trim(),
|
||||
name: String(profile.full_name || "there").trim(),
|
||||
}))
|
||||
.filter((recipient) => /.+@.+\..+/.test(recipient.email));
|
||||
|
||||
if (recipients.length === 0) {
|
||||
return jsonResponse({ success: true, sent: 0, skipped: recipientIds.length, reason: "No recipient email addresses found" });
|
||||
}
|
||||
|
||||
const { data: defaultSender, error: senderError } = await adminClient
|
||||
.from("email_senders")
|
||||
.select(senderSelectFields)
|
||||
.eq("is_active", true)
|
||||
.eq("is_default", true)
|
||||
.order("updated_at", { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
if (senderError || !defaultSender) {
|
||||
return jsonResponse({ success: false, error: "No default SMTP sender configured" }, 404);
|
||||
}
|
||||
|
||||
const sender = mapSenderRowToConfig(await resolveLatestSenderVariant(adminClient, defaultSender));
|
||||
const preview = message.replace(/\s+/g, " ").slice(0, 600);
|
||||
const subject = `New message from ${senderName}`;
|
||||
let sent = 0;
|
||||
const failed: { email: string; error: string }[] = [];
|
||||
|
||||
for (const recipient of recipients) {
|
||||
const htmlContent = ensureHtmlDocument(`
|
||||
<div style="font-family:Arial,sans-serif;max-width:640px;margin:0 auto;padding:24px;color:#172033;line-height:1.5;">
|
||||
<h2 style="margin:0 0 16px;color:#1e3a8a;font-size:20px;">New message from ${escapeHtml(senderName)}</h2>
|
||||
<p style="margin:0 0 14px;">Hi ${escapeHtml(recipient.name)},</p>
|
||||
<p style="margin:0 0 16px;">You received a new message in Avria Community Management.</p>
|
||||
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin:18px 0;white-space:pre-wrap;">${escapeHtml(preview)}</div>
|
||||
<p style="margin:22px 0;"><a href="https://avria.cloud/dashboard/messages" style="background:#1e3a8a;color:#ffffff;padding:12px 18px;text-decoration:none;border-radius:6px;display:inline-block;font-weight:600;">Open messages</a></p>
|
||||
<p style="font-size:12px;color:#64748b;margin-top:24px;">Please sign in to reply.</p>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const result = await sendViaSMTP(sender, [recipient.email], [], [], subject, htmlContent, [], false);
|
||||
const latestServerResponse = result.smtpResponse
|
||||
? `S: ${result.smtpResponse}`
|
||||
: [...result.diagnosticLogs].reverse().find((entry) => entry.startsWith("S: ")) ?? null;
|
||||
|
||||
try {
|
||||
await adminClient.from("email_history").insert({
|
||||
user_id: auth.callerId,
|
||||
sender_email: sender.from,
|
||||
recipient_email: recipient.email,
|
||||
subject,
|
||||
body_text: htmlContent,
|
||||
sent_at: new Date().toISOString(),
|
||||
status: result.success ? "accepted" : "failed",
|
||||
feature_type: "direct_message_notification",
|
||||
email_headers: result.success ? { smtp_response: latestServerResponse } : { error: result.error },
|
||||
});
|
||||
} catch (logErr) {
|
||||
console.error("Failed to log direct message notification email:", logErr);
|
||||
}
|
||||
|
||||
if (result.success) sent += 1;
|
||||
else failed.push({ email: recipient.email, error: result.error || "SMTP send failed" });
|
||||
}
|
||||
|
||||
return jsonResponse({ success: failed.length === 0, sent, failed });
|
||||
}
|
||||
|
||||
// === Send Email ===
|
||||
const { recipient, cc, bcc, subject, body, html, attachments, debug } = payload;
|
||||
if (!recipient || !subject) {
|
||||
return jsonResponse({ success: false, error: "Missing required fields: recipient, subject" }, 400);
|
||||
}
|
||||
|
||||
const resolvedSender: any = await resolveSender(payload, adminClient, req);
|
||||
if (resolvedSender.error) return resolvedSender.error;
|
||||
|
||||
const sender = resolvedSender.sender as SenderConfig;
|
||||
console.log("[send-smtp-email] Resolved sender:", JSON.stringify({
|
||||
host: sender.host,
|
||||
port: sender.port,
|
||||
username: sender.username,
|
||||
from: sender.from,
|
||||
fromEmail: sender.fromEmail,
|
||||
envelopeFrom: sender.envelopeFrom,
|
||||
use_ssl: sender.use_ssl,
|
||||
use_tls: sender.use_tls,
|
||||
hasPassword: !!sender.password,
|
||||
passwordLength: sender.password?.length ?? 0,
|
||||
}));
|
||||
|
||||
const toList = Array.isArray(recipient) ? recipient : [recipient];
|
||||
const ccList = Array.isArray(cc) ? cc.filter(Boolean) : [];
|
||||
const bccList = Array.isArray(bcc) ? bcc.filter(Boolean) : [];
|
||||
let htmlContent = html || body || "";
|
||||
|
||||
// Append sender signature if configured
|
||||
if (sender.signature_html) {
|
||||
const sigBlock = `<br/><div style="margin-top:16px;border-top:1px solid #e0e0e0;padding-top:12px">${sender.signature_html}</div>`;
|
||||
if (htmlContent.includes("</body>")) {
|
||||
htmlContent = htmlContent.replace("</body>", `${sigBlock}</body>`);
|
||||
} else {
|
||||
htmlContent += sigBlock;
|
||||
}
|
||||
}
|
||||
|
||||
// Inject a 1x1 tracking pixel so opens get recorded into email_history.
|
||||
const trackingId = crypto.randomUUID();
|
||||
const pixelUrl = `${getPublicFunctionBaseUrl()}/functions/v1/track-email-open?tid=${trackingId}`;
|
||||
const pixelTag = `<img src="${pixelUrl}" width="1" height="1" alt="" style="display:none!important;border:0;outline:none;text-decoration:none;width:1px;height:1px;opacity:0;overflow:hidden;" />`;
|
||||
if (htmlContent.includes("</body>")) {
|
||||
htmlContent = htmlContent.replace("</body>", `${pixelTag}</body>`);
|
||||
} else {
|
||||
htmlContent += pixelTag;
|
||||
}
|
||||
|
||||
const formattedAttachments = Array.isArray(attachments) ? attachments : [];
|
||||
|
||||
const result = await sendViaSMTP(
|
||||
sender,
|
||||
toList,
|
||||
ccList,
|
||||
bccList,
|
||||
subject,
|
||||
htmlContent,
|
||||
formattedAttachments,
|
||||
debug ?? false
|
||||
);
|
||||
|
||||
console.log("[send-smtp-email] SMTP result:", JSON.stringify({ success: result.success, error: result.error, smtpResponse: result.smtpResponse }));
|
||||
|
||||
const latestServerResponse = result.smtpResponse
|
||||
? `S: ${result.smtpResponse}`
|
||||
: [...result.diagnosticLogs].reverse().find((entry) => entry.startsWith("S: ")) ?? null;
|
||||
const mailStatus = result.success ? "accepted" : "failed";
|
||||
|
||||
// Log to email_history with accurate status
|
||||
let historyRecorded = false;
|
||||
let historyError: string | undefined;
|
||||
try {
|
||||
const userId = resolvedSender.callerId ?? payload.fallback_user_id;
|
||||
if (userId) {
|
||||
const { error: insertHistoryError } = await adminClient.from("email_history").insert({
|
||||
user_id: userId,
|
||||
sender_email: sender.from,
|
||||
recipient_email: toList.join(", "),
|
||||
subject,
|
||||
body_text: htmlContent,
|
||||
sent_at: new Date().toISOString(),
|
||||
status: mailStatus,
|
||||
feature_type: payload.feature_type || "smtp_email",
|
||||
tracking_id: trackingId,
|
||||
email_headers: result.success
|
||||
? { smtp_response: latestServerResponse }
|
||||
: { error: result.error, diagnostic_logs: debug ? result.diagnosticLogs : undefined },
|
||||
});
|
||||
|
||||
if (insertHistoryError) {
|
||||
historyError = insertHistoryError.message;
|
||||
console.error("Failed to log email history:", insertHistoryError);
|
||||
} else {
|
||||
historyRecorded = true;
|
||||
}
|
||||
}
|
||||
} catch (logErr) {
|
||||
historyError = logErr instanceof Error ? logErr.message : String(logErr);
|
||||
console.error("Failed to log email history:", logErr);
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
return jsonResponse({
|
||||
success: false,
|
||||
error: result.error,
|
||||
status: mailStatus,
|
||||
diagnostic_logs: debug ? result.diagnosticLogs : undefined,
|
||||
}, 500);
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
status: mailStatus,
|
||||
history_recorded: historyRecorded,
|
||||
history_error: historyError,
|
||||
smtp_response: latestServerResponse,
|
||||
diagnostic_logs: debug ? result.diagnosticLogs : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("send-smtp-email error:", error);
|
||||
return jsonResponse({ success: false, error: (error as Error).message }, 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
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",
|
||||
};
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||
|
||||
try {
|
||||
const { task_title, assigned_to_user_id, assigned_by_name } = await req.json();
|
||||
const authHeader = req.headers.get("Authorization") || req.headers.get("authorization") || "";
|
||||
|
||||
if (!authHeader) {
|
||||
return new Response(JSON.stringify({ success: false, error: "Unauthorized" }), {
|
||||
status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, serviceKey);
|
||||
|
||||
const { data: userData, error: userError } = await supabase.auth.admin.getUserById(assigned_to_user_id);
|
||||
if (userError || !userData?.user?.email) {
|
||||
return new Response(JSON.stringify({ success: false, error: "User email not found" }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const recipientEmail = userData.user.email;
|
||||
const idempotencyKey = `task-${assigned_to_user_id}-${task_title}-${Date.now()}`;
|
||||
|
||||
const { data: result, error: invokeErr } = await supabase.functions.invoke("send-transactional-email", {
|
||||
body: {
|
||||
templateName: "task-notification",
|
||||
recipientEmail,
|
||||
idempotencyKey,
|
||||
templateData: {
|
||||
taskTitle: task_title,
|
||||
assignedByName: assigned_by_name || "Admin",
|
||||
link: "https://avria.cloud/dashboard/tasks",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (invokeErr) {
|
||||
console.error("send-transactional-email error:", invokeErr);
|
||||
return new Response(JSON.stringify({ success: false, error: invokeErr.message }), {
|
||||
status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ success: true, result }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
return new Response(JSON.stringify({ success: false, error: (error as Error).message }), {
|
||||
status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.45.0'
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
}
|
||||
|
||||
const PUBLIC_BASE_URL = 'https://avria.cloud'
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
|
||||
|
||||
try {
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const ownerIds: string[] = Array.isArray(body.owner_ids) ? body.owner_ids.filter((x: any) => typeof x === 'string') : []
|
||||
const customMessage: string = typeof body.custom_message === 'string' ? body.custom_message.trim() : ''
|
||||
|
||||
if (ownerIds.length === 0) {
|
||||
return new Response(JSON.stringify({ error: 'owner_ids required' }), {
|
||||
status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!
|
||||
const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||||
const anonKey = Deno.env.get('SUPABASE_ANON_KEY')!
|
||||
const authHeader = req.headers.get('Authorization') || ''
|
||||
|
||||
const authClient = createClient(supabaseUrl, anonKey, {
|
||||
global: { headers: { Authorization: authHeader } },
|
||||
})
|
||||
const { data: userRes } = await authClient.auth.getUser()
|
||||
if (!userRes?.user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
const userId = userRes.user.id
|
||||
const admin = createClient(supabaseUrl, serviceKey)
|
||||
|
||||
const { data: roleRows } = await admin.from('user_roles').select('role').eq('user_id', userId)
|
||||
const roles = (roleRows || []).map((r: any) => r.role)
|
||||
if (!roles.includes('admin') && !roles.includes('manager')) {
|
||||
return new Response(JSON.stringify({ error: 'Forbidden' }), {
|
||||
status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const { data: profile } = await admin.from('profiles').select('full_name').eq('user_id', userId).maybeSingle()
|
||||
const requesterName = profile?.full_name || 'Avria Community Management'
|
||||
|
||||
const { data: owners, error: oErr } = await admin
|
||||
.from('owners')
|
||||
.select('id, first_name, last_name, email, management_contact_email, management_contact_name, business_name, owner_type, unit_id, association_id, property_address, user_id, associations(name), units(address)')
|
||||
.in('id', ownerIds)
|
||||
if (oErr) {
|
||||
return new Response(JSON.stringify({ error: 'Failed to load owners', detail: oErr.message }), {
|
||||
status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const results: Array<{ owner_id: string; ok: boolean; link?: string; error?: string; sent_to?: string }> = []
|
||||
|
||||
for (const owner of owners || []) {
|
||||
const ownerName = (owner as any).business_name
|
||||
|| (owner as any).management_contact_name
|
||||
|| [owner.first_name, owner.last_name].filter(Boolean).join(' ')
|
||||
|| 'Owner'
|
||||
const propertyAddress = (owner as any).units?.address || owner.property_address || ''
|
||||
const associationName = (owner as any).associations?.name || ''
|
||||
let recipientEmail = owner.email || (owner as any).management_contact_email || ''
|
||||
|
||||
// Fallback: if owner has a linked user_id, try profiles.email
|
||||
if (!recipientEmail && (owner as any).user_id) {
|
||||
const { data: profileRow } = await admin
|
||||
.from('profiles')
|
||||
.select('email')
|
||||
.eq('user_id', (owner as any).user_id)
|
||||
.maybeSingle()
|
||||
if (profileRow?.email) {
|
||||
recipientEmail = profileRow.email
|
||||
}
|
||||
}
|
||||
|
||||
if (!recipientEmail) {
|
||||
results.push({ owner_id: owner.id, ok: false, error: 'No email on file' })
|
||||
continue
|
||||
}
|
||||
|
||||
const { data: reqRow, error: reqErr } = await admin
|
||||
.from('tenant_info_requests')
|
||||
.insert({
|
||||
owner_id: owner.id,
|
||||
unit_id: owner.unit_id,
|
||||
association_id: owner.association_id,
|
||||
sent_to_email: recipientEmail,
|
||||
custom_message: customMessage || null,
|
||||
created_by: userId,
|
||||
})
|
||||
.select('id, token, expires_at')
|
||||
.single()
|
||||
|
||||
if (reqErr || !reqRow) {
|
||||
results.push({ owner_id: owner.id, ok: false, error: reqErr?.message || 'Failed to create request' })
|
||||
continue
|
||||
}
|
||||
|
||||
const link = `${PUBLIC_BASE_URL}/tenant-info/${reqRow.token}`
|
||||
const expiresAt = new Date(reqRow.expires_at).toLocaleDateString('en-US', {
|
||||
month: 'long', day: 'numeric', year: 'numeric',
|
||||
})
|
||||
|
||||
let emailErr: any = null
|
||||
try {
|
||||
const emailRes = await fetch(`${supabaseUrl}/functions/v1/send-transactional-email`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'apikey': anonKey,
|
||||
...(authHeader ? { Authorization: authHeader } : {}),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
templateName: 'tenant-info-request',
|
||||
recipientEmail,
|
||||
idempotencyKey: `tenant-info-${reqRow.id}`,
|
||||
templateData: {
|
||||
ownerName,
|
||||
propertyAddress,
|
||||
associationName,
|
||||
requesterName,
|
||||
customMessage: customMessage || undefined,
|
||||
link,
|
||||
expiresAt,
|
||||
},
|
||||
}),
|
||||
})
|
||||
if (!emailRes.ok) {
|
||||
const text = await emailRes.text()
|
||||
emailErr = { message: `status ${emailRes.status}: ${text}` }
|
||||
}
|
||||
} catch (e) {
|
||||
emailErr = e
|
||||
}
|
||||
|
||||
if (emailErr) {
|
||||
console.error('Email send failed:', emailErr)
|
||||
results.push({ owner_id: owner.id, ok: false, link, error: 'Email failed to send. Share the link manually.' })
|
||||
} else {
|
||||
results.push({ owner_id: owner.id, ok: true, link, sent_to: recipientEmail })
|
||||
}
|
||||
}
|
||||
|
||||
const sentCount = results.filter((r) => r.ok).length
|
||||
return new Response(JSON.stringify({ ok: true, sent: sentCount, total: results.length, results }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('send-tenant-info-request error:', e)
|
||||
return new Response(JSON.stringify({ error: String(e) }), {
|
||||
status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "npm:react@18.3.1",
|
||||
"types": ["npm:@types/react@18.3.1"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import * as React from 'npm:react@18.3.1'
|
||||
import { renderAsync } from 'npm:@react-email/components@0.0.22'
|
||||
import { createClient } from 'npm:@supabase/supabase-js@2'
|
||||
import { corsHeaders } from 'npm:@supabase/supabase-js@2/cors'
|
||||
import { TEMPLATES } from '../_shared/transactional-email-templates/registry.ts'
|
||||
|
||||
// Configuration baked in at scaffold time — do NOT change these manually.
|
||||
// To update, re-run the email domain setup flow.
|
||||
const SITE_NAME = "avria-community-cloud"
|
||||
// SENDER_DOMAIN is the verified sender subdomain FQDN (e.g., "notify.example.com").
|
||||
// It MUST match the subdomain delegated to Lovable's nameservers — never the root domain.
|
||||
// The email API looks up this exact domain; a mismatch causes "No email domain record found".
|
||||
const SENDER_DOMAIN = "notify.avriamail.com"
|
||||
// FROM_DOMAIN is the domain shown in the From: header (e.g., "example.com").
|
||||
// When display_from_root is enabled, this can be the root domain for cleaner branding,
|
||||
// even though actual sending uses the subdomain above.
|
||||
const FROM_DOMAIN = "notify.avriamail.com"
|
||||
|
||||
// Generate a cryptographically random 32-byte hex token
|
||||
function generateToken(): string {
|
||||
const bytes = new Uint8Array(32)
|
||||
crypto.getRandomValues(bytes)
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
// Auth note: this function is invoked by trusted app flows and other functions.
|
||||
// It uses the service client internally for queueing and suppression checks.
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
// Handle CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')
|
||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
console.error('Missing required environment variables')
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Server configuration error' }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
let templateName: string
|
||||
let recipientEmail: string
|
||||
let idempotencyKey: string
|
||||
let messageId: string
|
||||
let templateData: Record<string, any> = {}
|
||||
try {
|
||||
const body = await req.json()
|
||||
templateName = body.templateName || body.template_name
|
||||
recipientEmail = body.recipientEmail || body.recipient_email
|
||||
messageId = crypto.randomUUID()
|
||||
idempotencyKey = body.idempotencyKey || body.idempotency_key || messageId
|
||||
if (body.templateData && typeof body.templateData === 'object') {
|
||||
templateData = body.templateData
|
||||
}
|
||||
} catch {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Invalid JSON in request body' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (!templateName) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'templateName is required' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 1. Look up template from registry (early — needed to resolve recipient)
|
||||
const template = TEMPLATES[templateName]
|
||||
|
||||
if (!template) {
|
||||
console.error('Template not found in registry', { templateName })
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: `Template '${templateName}' not found. Available: ${Object.keys(TEMPLATES).join(', ')}`,
|
||||
}),
|
||||
{
|
||||
status: 404,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve effective recipient: template-level `to` takes precedence over
|
||||
// the caller-provided recipientEmail. This allows notification templates
|
||||
// to always send to a fixed address (e.g., site owner from env var).
|
||||
const effectiveRecipient = template.to || recipientEmail
|
||||
|
||||
if (!effectiveRecipient) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'recipientEmail is required (unless the template defines a fixed recipient)',
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Create Supabase client with service role (bypasses RLS)
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
// 2. Check suppression list (fail-closed: if we can't verify, don't send)
|
||||
const { data: suppressed, error: suppressionError } = await supabase
|
||||
.from('suppressed_emails')
|
||||
.select('id')
|
||||
.eq('email', effectiveRecipient.toLowerCase())
|
||||
.maybeSingle()
|
||||
|
||||
if (suppressionError) {
|
||||
console.error('Suppression check failed — refusing to send', {
|
||||
error: suppressionError,
|
||||
effectiveRecipient,
|
||||
})
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to verify suppression status' }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (suppressed) {
|
||||
// Log the suppressed attempt
|
||||
await supabase.from('email_send_log').insert({
|
||||
message_id: messageId,
|
||||
template_name: templateName,
|
||||
recipient_email: effectiveRecipient,
|
||||
status: 'suppressed',
|
||||
})
|
||||
|
||||
console.log('Email suppressed', { effectiveRecipient, templateName })
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, reason: 'email_suppressed' }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Get or create unsubscribe token (one token per email address)
|
||||
const normalizedEmail = effectiveRecipient.toLowerCase()
|
||||
let unsubscribeToken: string
|
||||
|
||||
// Check for existing token for this email
|
||||
const { data: existingToken, error: tokenLookupError } = await supabase
|
||||
.from('email_unsubscribe_tokens')
|
||||
.select('token, used_at')
|
||||
.eq('email', normalizedEmail)
|
||||
.maybeSingle()
|
||||
|
||||
if (tokenLookupError) {
|
||||
console.error('Token lookup failed', {
|
||||
error: tokenLookupError,
|
||||
email: normalizedEmail,
|
||||
})
|
||||
await supabase.from('email_send_log').insert({
|
||||
message_id: messageId,
|
||||
template_name: templateName,
|
||||
recipient_email: effectiveRecipient,
|
||||
status: 'failed',
|
||||
error_message: 'Failed to look up unsubscribe token',
|
||||
})
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to prepare email' }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (existingToken && !existingToken.used_at) {
|
||||
// Reuse existing unused token
|
||||
unsubscribeToken = existingToken.token
|
||||
} else if (!existingToken) {
|
||||
// Create new token — upsert handles concurrent inserts gracefully
|
||||
unsubscribeToken = generateToken()
|
||||
const { error: tokenError } = await supabase
|
||||
.from('email_unsubscribe_tokens')
|
||||
.upsert(
|
||||
{ token: unsubscribeToken, email: normalizedEmail },
|
||||
{ onConflict: 'email', ignoreDuplicates: true }
|
||||
)
|
||||
|
||||
if (tokenError) {
|
||||
console.error('Failed to create unsubscribe token', {
|
||||
error: tokenError,
|
||||
})
|
||||
await supabase.from('email_send_log').insert({
|
||||
message_id: messageId,
|
||||
template_name: templateName,
|
||||
recipient_email: effectiveRecipient,
|
||||
status: 'failed',
|
||||
error_message: 'Failed to create unsubscribe token',
|
||||
})
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to prepare email' }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// If another request raced us, our upsert was silently ignored.
|
||||
// Re-read to get the actual stored token.
|
||||
const { data: storedToken, error: reReadError } = await supabase
|
||||
.from('email_unsubscribe_tokens')
|
||||
.select('token')
|
||||
.eq('email', normalizedEmail)
|
||||
.maybeSingle()
|
||||
|
||||
if (reReadError || !storedToken) {
|
||||
console.error('Failed to read back unsubscribe token after upsert', {
|
||||
error: reReadError,
|
||||
email: normalizedEmail,
|
||||
})
|
||||
await supabase.from('email_send_log').insert({
|
||||
message_id: messageId,
|
||||
template_name: templateName,
|
||||
recipient_email: effectiveRecipient,
|
||||
status: 'failed',
|
||||
error_message: 'Failed to confirm unsubscribe token storage',
|
||||
})
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to prepare email' }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
unsubscribeToken = storedToken.token
|
||||
} else {
|
||||
// Token exists but is already used — email should have been caught by suppression check above.
|
||||
// This is a safety fallback; log and skip sending.
|
||||
console.warn('Unsubscribe token already used but email not suppressed', {
|
||||
email: normalizedEmail,
|
||||
})
|
||||
await supabase.from('email_send_log').insert({
|
||||
message_id: messageId,
|
||||
template_name: templateName,
|
||||
recipient_email: effectiveRecipient,
|
||||
status: 'suppressed',
|
||||
error_message:
|
||||
'Unsubscribe token used but email missing from suppressed list',
|
||||
})
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, reason: 'email_suppressed' }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Render React Email template to HTML and plain text
|
||||
const html = await renderAsync(
|
||||
React.createElement(template.component, templateData)
|
||||
)
|
||||
const plainText = await renderAsync(
|
||||
React.createElement(template.component, templateData),
|
||||
{ plainText: true }
|
||||
)
|
||||
|
||||
// Resolve subject — supports static string or dynamic function
|
||||
const resolvedSubject =
|
||||
typeof template.subject === 'function'
|
||||
? template.subject(templateData)
|
||||
: template.subject
|
||||
|
||||
// 5. Enqueue the pre-rendered email for async processing by the dispatcher.
|
||||
// The dispatcher (process-email-queue) handles sending, retries, and rate-limit backoff.
|
||||
|
||||
// Log pending BEFORE enqueue so we have a record even if enqueue crashes
|
||||
await supabase.from('email_send_log').insert({
|
||||
message_id: messageId,
|
||||
template_name: templateName,
|
||||
recipient_email: effectiveRecipient,
|
||||
status: 'pending',
|
||||
})
|
||||
|
||||
const { error: enqueueError } = await supabase.rpc('enqueue_email', {
|
||||
queue_name: 'transactional_emails',
|
||||
payload: {
|
||||
message_id: messageId,
|
||||
to: effectiveRecipient,
|
||||
from: `${SITE_NAME} <noreply@${FROM_DOMAIN}>`,
|
||||
sender_domain: SENDER_DOMAIN,
|
||||
subject: resolvedSubject,
|
||||
html,
|
||||
text: plainText,
|
||||
purpose: 'transactional',
|
||||
label: templateName,
|
||||
idempotency_key: idempotencyKey,
|
||||
unsubscribe_token: unsubscribeToken,
|
||||
queued_at: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
if (enqueueError) {
|
||||
console.error('Failed to enqueue email', {
|
||||
error: enqueueError,
|
||||
templateName,
|
||||
effectiveRecipient,
|
||||
})
|
||||
|
||||
await supabase.from('email_send_log').insert({
|
||||
message_id: messageId,
|
||||
template_name: templateName,
|
||||
recipient_email: effectiveRecipient,
|
||||
status: 'failed',
|
||||
error_message: 'Failed to enqueue email',
|
||||
})
|
||||
|
||||
return new Response(JSON.stringify({ error: 'Failed to enqueue email' }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
console.log('Transactional email enqueued', { templateName, effectiveRecipient })
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, queued: true }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.45.0'
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
}
|
||||
|
||||
const PUBLIC_BASE_URL = 'https://avria.cloud'
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
|
||||
|
||||
try {
|
||||
const { vendor_id } = await req.json()
|
||||
if (!vendor_id) {
|
||||
return new Response(JSON.stringify({ error: 'vendor_id required' }), {
|
||||
status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!
|
||||
const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||||
const anonKey = Deno.env.get('SUPABASE_ANON_KEY')!
|
||||
const authHeader = req.headers.get('Authorization') || ''
|
||||
|
||||
// Auth client to validate caller
|
||||
const authClient = createClient(supabaseUrl, anonKey, {
|
||||
global: { headers: { Authorization: authHeader } },
|
||||
})
|
||||
const { data: userRes } = await authClient.auth.getUser()
|
||||
if (!userRes?.user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
const userId = userRes.user.id
|
||||
|
||||
const admin = createClient(supabaseUrl, serviceKey)
|
||||
|
||||
// Verify caller is admin or manager
|
||||
const { data: roleRows } = await admin
|
||||
.from('user_roles').select('role').eq('user_id', userId)
|
||||
const roles = (roleRows || []).map((r: any) => r.role)
|
||||
if (!roles.includes('admin') && !roles.includes('manager')) {
|
||||
return new Response(JSON.stringify({ error: 'Forbidden' }), {
|
||||
status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
// Load vendor
|
||||
const { data: vendor, error: vErr } = await admin
|
||||
.from('vendors').select('id, name, email').eq('id', vendor_id).single()
|
||||
if (vErr || !vendor) {
|
||||
return new Response(JSON.stringify({ error: 'Vendor not found' }), {
|
||||
status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
if (!vendor.email) {
|
||||
return new Response(JSON.stringify({ error: 'Vendor has no email on file' }), {
|
||||
status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
// Get profile name for requester (best-effort)
|
||||
const { data: profile } = await admin
|
||||
.from('profiles').select('full_name').eq('user_id', userId).maybeSingle()
|
||||
|
||||
// Create request token
|
||||
const { data: reqRow, error: reqErr } = await admin
|
||||
.from('vendor_insurance_requests')
|
||||
.insert({
|
||||
vendor_id,
|
||||
sent_to_email: vendor.email,
|
||||
created_by: userId,
|
||||
})
|
||||
.select('id, token, expires_at')
|
||||
.single()
|
||||
if (reqErr || !reqRow) {
|
||||
return new Response(JSON.stringify({ error: 'Failed to create request', detail: reqErr?.message }), {
|
||||
status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const link = `${PUBLIC_BASE_URL}/vendor-insurance/${reqRow.token}`
|
||||
const expiresAt = new Date(reqRow.expires_at).toLocaleDateString('en-US', {
|
||||
month: 'long', day: 'numeric', year: 'numeric',
|
||||
})
|
||||
|
||||
// Send email via shared transactional sender using the caller's JWT.
|
||||
// Some deployed environments still enforce gateway JWT verification even
|
||||
// when local config says verify_jwt=false, so forwarding the staff session
|
||||
// avoids the nested call being rejected before it reaches the sender.
|
||||
let emailErr: any = null
|
||||
try {
|
||||
const emailRes = await fetch(`${supabaseUrl}/functions/v1/send-transactional-email`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'apikey': anonKey,
|
||||
...(authHeader ? { Authorization: authHeader } : {}),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
templateName: 'vendor-insurance-request',
|
||||
recipientEmail: vendor.email,
|
||||
idempotencyKey: `vendor-insurance-${reqRow.id}`,
|
||||
templateData: {
|
||||
vendorName: vendor.name,
|
||||
requesterName: profile?.full_name || 'Avria Community Management',
|
||||
link,
|
||||
expiresAt,
|
||||
},
|
||||
}),
|
||||
})
|
||||
if (!emailRes.ok) {
|
||||
const text = await emailRes.text()
|
||||
emailErr = { message: `status ${emailRes.status}: ${text}` }
|
||||
}
|
||||
} catch (e) {
|
||||
emailErr = e
|
||||
}
|
||||
|
||||
if (emailErr) {
|
||||
console.error('Email send failed:', emailErr)
|
||||
return new Response(JSON.stringify({
|
||||
ok: false,
|
||||
request_id: reqRow.id,
|
||||
link,
|
||||
error: 'Email failed to send. Share the link manually.',
|
||||
}), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } })
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
request_id: reqRow.id,
|
||||
link,
|
||||
sent_to: vendor.email,
|
||||
}), { headers: { ...corsHeaders, 'Content-Type': 'application/json' } })
|
||||
} catch (e) {
|
||||
console.error('send-vendor-insurance-request error:', e)
|
||||
return new Response(JSON.stringify({ error: String(e) }), {
|
||||
status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.45.0'
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
}
|
||||
|
||||
const PUBLIC_BASE_URL = 'https://avria.cloud'
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
|
||||
|
||||
try {
|
||||
const { vendor_id } = await req.json()
|
||||
if (!vendor_id) {
|
||||
return new Response(JSON.stringify({ error: 'vendor_id required' }), {
|
||||
status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!
|
||||
const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||||
const anonKey = Deno.env.get('SUPABASE_ANON_KEY')!
|
||||
const authHeader = req.headers.get('Authorization') || ''
|
||||
|
||||
const authClient = createClient(supabaseUrl, anonKey, {
|
||||
global: { headers: { Authorization: authHeader } },
|
||||
})
|
||||
const { data: userRes } = await authClient.auth.getUser()
|
||||
if (!userRes?.user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
const userId = userRes.user.id
|
||||
|
||||
const admin = createClient(supabaseUrl, serviceKey)
|
||||
|
||||
const { data: roleRows } = await admin
|
||||
.from('user_roles').select('role').eq('user_id', userId)
|
||||
const roles = (roleRows || []).map((r: any) => r.role)
|
||||
if (!roles.includes('admin') && !roles.includes('manager')) {
|
||||
return new Response(JSON.stringify({ error: 'Forbidden' }), {
|
||||
status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const { data: vendor, error: vErr } = await admin
|
||||
.from('vendors').select('id, name, email').eq('id', vendor_id).single()
|
||||
if (vErr || !vendor) {
|
||||
return new Response(JSON.stringify({ error: 'Vendor not found' }), {
|
||||
status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
if (!vendor.email) {
|
||||
return new Response(JSON.stringify({ error: 'Vendor has no email on file' }), {
|
||||
status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const { data: profile } = await admin
|
||||
.from('profiles').select('full_name').eq('user_id', userId).maybeSingle()
|
||||
|
||||
const { data: reqRow, error: reqErr } = await admin
|
||||
.from('vendor_profile_requests')
|
||||
.insert({ vendor_id, sent_to_email: vendor.email, created_by: userId })
|
||||
.select('id, token, expires_at')
|
||||
.single()
|
||||
if (reqErr || !reqRow) {
|
||||
return new Response(JSON.stringify({ error: 'Failed to create request', detail: reqErr?.message }), {
|
||||
status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const link = `${PUBLIC_BASE_URL}/vendor-profile/${reqRow.token}`
|
||||
const expiresAt = new Date(reqRow.expires_at).toLocaleDateString('en-US', {
|
||||
month: 'long', day: 'numeric', year: 'numeric',
|
||||
})
|
||||
|
||||
// Send email via shared app email sender using the caller's JWT.
|
||||
// Some deployed environments still enforce gateway JWT verification even
|
||||
// when local config says verify_jwt=false, so forwarding the staff session
|
||||
// avoids the nested call being rejected before it reaches the sender.
|
||||
let emailErr: any = null
|
||||
try {
|
||||
const emailRes = await fetch(`${supabaseUrl}/functions/v1/send-transactional-email`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'apikey': anonKey,
|
||||
...(authHeader ? { Authorization: authHeader } : {}),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
templateName: 'vendor-profile-request',
|
||||
recipientEmail: vendor.email,
|
||||
idempotencyKey: `vendor-profile-${reqRow.id}`,
|
||||
templateData: {
|
||||
vendorName: vendor.name,
|
||||
requesterName: profile?.full_name || 'Avria Community Management',
|
||||
link,
|
||||
expiresAt,
|
||||
},
|
||||
}),
|
||||
})
|
||||
if (!emailRes.ok) {
|
||||
const text = await emailRes.text()
|
||||
emailErr = { message: `status ${emailRes.status}: ${text}` }
|
||||
}
|
||||
} catch (e) {
|
||||
emailErr = e
|
||||
}
|
||||
|
||||
if (emailErr) {
|
||||
console.error('Email send failed:', emailErr)
|
||||
return new Response(JSON.stringify({
|
||||
ok: false,
|
||||
request_id: reqRow.id,
|
||||
link,
|
||||
error: 'Email failed to send. Share the link manually.',
|
||||
}), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } })
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
ok: true, request_id: reqRow.id, link, sent_to: vendor.email,
|
||||
}), { headers: { ...corsHeaders, 'Content-Type': 'application/json' } })
|
||||
} catch (e) {
|
||||
console.error('send-vendor-profile-request error:', e)
|
||||
return new Response(JSON.stringify({ error: String(e) }), {
|
||||
status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.3";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Verify the calling user
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser(token);
|
||||
if (authError || !user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { association_id, owner_id, unit_id, payment_method_type } = body;
|
||||
|
||||
if (!association_id) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "association_id is required" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Get Stripe mapping for this association
|
||||
const { data: mapping, error: mappingError } = await supabase
|
||||
.from("stripe_account_mappings")
|
||||
.select("*")
|
||||
.eq("association_id", association_id)
|
||||
.eq("is_active", true)
|
||||
.maybeSingle();
|
||||
|
||||
if (mappingError || !mapping?.stripe_secret_key) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "No active Stripe configuration found for this association.",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const stripeSecretKey = mapping.stripe_secret_key;
|
||||
|
||||
// Get or create Stripe customer
|
||||
// Check if there's already an enrollment with a customer ID for this user+association
|
||||
const { data: existingEnrollment } = await supabase
|
||||
.from("autopay_enrollments")
|
||||
.select("stripe_customer_id")
|
||||
.eq("association_id", association_id)
|
||||
.eq("enrolled_by", user.id)
|
||||
.not("stripe_customer_id", "is", null)
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
let stripeCustomerId = existingEnrollment?.stripe_customer_id;
|
||||
|
||||
if (!stripeCustomerId) {
|
||||
// Create a new Stripe customer
|
||||
const customerParams = new URLSearchParams();
|
||||
customerParams.append("email", user.email || "");
|
||||
customerParams.append("metadata[user_id]", user.id);
|
||||
customerParams.append("metadata[association_id]", association_id);
|
||||
|
||||
const customerRes = await fetch(
|
||||
"https://api.stripe.com/v1/customers",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${stripeSecretKey}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: customerParams.toString(),
|
||||
}
|
||||
);
|
||||
|
||||
const customerData = await customerRes.json();
|
||||
if (!customerRes.ok) {
|
||||
console.error("Stripe customer error:", customerData);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error:
|
||||
customerData.error?.message || "Failed to create Stripe customer",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
stripeCustomerId = customerData.id;
|
||||
}
|
||||
|
||||
// Create a SetupIntent to save a payment method
|
||||
const setupParams = new URLSearchParams();
|
||||
setupParams.append("customer", stripeCustomerId!);
|
||||
setupParams.append("usage", "off_session");
|
||||
|
||||
const isAch = payment_method_type === "us_bank_account";
|
||||
if (isAch) {
|
||||
setupParams.append("payment_method_types[]", "us_bank_account");
|
||||
setupParams.append(
|
||||
"payment_method_options[us_bank_account][financial_connections][permissions][]",
|
||||
"payment_method"
|
||||
);
|
||||
} else {
|
||||
setupParams.append("automatic_payment_methods[enabled]", "true");
|
||||
}
|
||||
|
||||
setupParams.append("metadata[association_id]", association_id);
|
||||
setupParams.append("metadata[user_id]", user.id);
|
||||
if (owner_id) setupParams.append("metadata[owner_id]", owner_id);
|
||||
if (unit_id) setupParams.append("metadata[unit_id]", unit_id);
|
||||
|
||||
const setupRes = await fetch(
|
||||
"https://api.stripe.com/v1/setup_intents",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${stripeSecretKey}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: setupParams.toString(),
|
||||
}
|
||||
);
|
||||
|
||||
const setupData = await setupRes.json();
|
||||
if (!setupRes.ok) {
|
||||
console.error("Stripe SetupIntent error:", setupData);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error:
|
||||
setupData.error?.message || "Failed to create SetupIntent",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
clientSecret: setupData.client_secret,
|
||||
setupIntentId: setupData.id,
|
||||
customerId: stripeCustomerId,
|
||||
publishableKey: mapping.stripe_publishable_key,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
console.error("Error in setup-autopay:", err);
|
||||
const message = err instanceof Error ? err.message : "Internal server error";
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.3";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, content-type",
|
||||
};
|
||||
|
||||
function stripeHeaders(secretKey: string, accountId?: string | null) {
|
||||
return {
|
||||
Authorization: `Bearer ${secretKey}`,
|
||||
...(accountId?.startsWith("acct_") ? { "Stripe-Account": accountId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
|
||||
);
|
||||
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const webhookUrl = `${supabaseUrl}/functions/v1/stripe-webhook`;
|
||||
|
||||
// Today 00:00 UTC -> unix
|
||||
const now = new Date();
|
||||
const startOfDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
const createdGte = Math.floor(startOfDay.getTime() / 1000);
|
||||
|
||||
const results: any[] = [];
|
||||
|
||||
try {
|
||||
const { data: mappings, error: mErr } = await supabase
|
||||
.from("stripe_account_mappings")
|
||||
.select("association_id, stripe_account_id, stripe_secret_key")
|
||||
.eq("is_active", true);
|
||||
if (mErr) throw mErr;
|
||||
|
||||
for (const m of mappings || []) {
|
||||
if (!m.stripe_secret_key) continue;
|
||||
|
||||
const url = `https://api.stripe.com/v1/payment_intents?limit=100&created[gte]=${createdGte}`;
|
||||
const r = await fetch(url, { headers: stripeHeaders(m.stripe_secret_key, m.stripe_account_id) });
|
||||
if (!r.ok) {
|
||||
results.push({ association_id: m.association_id, error: `list failed: ${r.status} ${await r.text()}` });
|
||||
continue;
|
||||
}
|
||||
const list = await r.json();
|
||||
|
||||
let processed = 0;
|
||||
let posted = 0;
|
||||
for (const pi of list.data || []) {
|
||||
if (pi.status !== "succeeded") continue;
|
||||
processed++;
|
||||
|
||||
const fakeEvent = {
|
||||
id: `evt_backfill_${pi.id}`,
|
||||
type: "payment_intent.succeeded",
|
||||
account: m.stripe_account_id,
|
||||
data: { object: pi },
|
||||
};
|
||||
|
||||
const wr = await fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(fakeEvent),
|
||||
});
|
||||
if (wr.ok) posted++;
|
||||
else console.error("webhook replay failed", pi.id, wr.status, await wr.text());
|
||||
}
|
||||
|
||||
results.push({
|
||||
association_id: m.association_id,
|
||||
stripe_account_id: m.stripe_account_id,
|
||||
succeeded_today: processed,
|
||||
replayed: posted,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ ok: true, since: startOfDay.toISOString(), results }), {
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("backfill error:", err);
|
||||
return new Response(JSON.stringify({ error: (err as Error).message, results }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
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, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader) {
|
||||
return new Response(JSON.stringify({ error: "Missing authorization" }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseAnonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
|
||||
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
|
||||
// Validate user via JWT claims (works with signing-keys auth)
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
const authClient = createClient(supabaseUrl, supabaseAnonKey);
|
||||
const { data: claimsData, error: userError } = await authClient.auth.getClaims(token);
|
||||
if (userError || !claimsData?.claims?.sub) {
|
||||
console.error("Auth failed:", userError);
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
const userId = claimsData.claims.sub;
|
||||
|
||||
// Check admin role
|
||||
const serviceClient = createClient(supabaseUrl, serviceRoleKey);
|
||||
const { data: roles } = await serviceClient
|
||||
.from("user_roles")
|
||||
.select("role")
|
||||
.eq("user_id", userId);
|
||||
|
||||
const isAdmin = roles?.some((r: any) => ["admin", "manager"].includes(r.role));
|
||||
if (!isAdmin) {
|
||||
return new Response(JSON.stringify({ error: "Forbidden" }), {
|
||||
status: 403,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const mappingId = url.searchParams.get("mapping_id");
|
||||
const limit = Math.min(parseInt(url.searchParams.get("limit") || "10"), 25);
|
||||
|
||||
// Fetch the stripe account mapping(s)
|
||||
let query = serviceClient
|
||||
.from("stripe_account_mappings")
|
||||
.select("id, stripe_secret_key, label, association_id, associations(name)")
|
||||
.eq("is_active", true);
|
||||
|
||||
if (mappingId) {
|
||||
query = query.eq("id", mappingId);
|
||||
}
|
||||
|
||||
const { data: mappings, error: mapError } = await query;
|
||||
if (mapError) throw mapError;
|
||||
|
||||
if (!mappings || mappings.length === 0) {
|
||||
return new Response(JSON.stringify({ transactions: [], accounts: [] }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// If no specific mapping requested, return accounts list + transactions from first account
|
||||
const accounts = mappings.map((m: any) => ({
|
||||
id: m.id,
|
||||
name: m.label || (m.associations as any)?.name || "Unknown Account",
|
||||
hasSecretKey: !!m.stripe_secret_key,
|
||||
}));
|
||||
|
||||
// Pick which mapping to fetch transactions from
|
||||
const targetMapping = mappingId
|
||||
? mappings[0]
|
||||
: mappings[0]; // default to first
|
||||
|
||||
if (!targetMapping?.stripe_secret_key) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
transactions: [],
|
||||
accounts,
|
||||
error: "No secret key configured for this account.",
|
||||
}),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch recent charges from Stripe
|
||||
const stripeRes = await fetch(
|
||||
`https://api.stripe.com/v1/charges?limit=${limit}&expand[]=data.customer`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${targetMapping.stripe_secret_key}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!stripeRes.ok) {
|
||||
const errBody = await stripeRes.text();
|
||||
console.error("Stripe API error:", stripeRes.status, errBody);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
transactions: [],
|
||||
accounts,
|
||||
error: `Stripe API error: ${stripeRes.status}`,
|
||||
}),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const stripeData = await stripeRes.json();
|
||||
|
||||
const transactions = (stripeData.data || []).map((charge: any) => ({
|
||||
id: charge.id,
|
||||
amount: charge.amount,
|
||||
currency: charge.currency,
|
||||
status: charge.status,
|
||||
created: charge.created,
|
||||
description: charge.description || null,
|
||||
customer_name: charge.customer?.name || null,
|
||||
customer_email: charge.customer?.email || charge.billing_details?.email || null,
|
||||
payment_method: charge.payment_method_details?.type || null,
|
||||
}));
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ transactions, accounts }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
console.error("Error in stripe-transactions:", error);
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.3";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, content-type, stripe-signature",
|
||||
};
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function timingSafeEqual(a: Uint8Array, b: Uint8Array) {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
function hexToBytes(hex: string) {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function verifyStripeSignature(rawBody: string, signatureHeader: string, secret: string) {
|
||||
const parts = signatureHeader.split(",").reduce<Record<string, string[]>>((acc, part) => {
|
||||
const [key, value] = part.split("=");
|
||||
if (!key || !value) return acc;
|
||||
acc[key] = [...(acc[key] || []), value];
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const timestamp = parts.t?.[0];
|
||||
const signatures = parts.v1 || [];
|
||||
if (!timestamp || signatures.length === 0) return false;
|
||||
|
||||
const payload = `${timestamp}.${rawBody}`;
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const expected = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(payload)));
|
||||
return signatures.some((sig) => timingSafeEqual(expected, hexToBytes(sig)));
|
||||
}
|
||||
|
||||
function stripeHeaders(secretKey: string, accountId?: string | null) {
|
||||
return {
|
||||
Authorization: `Bearer ${secretKey}`,
|
||||
...(accountId?.startsWith("acct_") ? { "Stripe-Account": accountId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
|
||||
);
|
||||
|
||||
const webhookSecret = Deno.env.get("STRIPE_WEBHOOK_SECRET");
|
||||
const sig = req.headers.get("stripe-signature");
|
||||
const rawBody = await req.text();
|
||||
|
||||
if (webhookSecret && sig) {
|
||||
const valid = await verifyStripeSignature(rawBody, sig, webhookSecret);
|
||||
if (!valid) {
|
||||
console.error("signature verification failed");
|
||||
return new Response("signature error", { status: 400, headers: corsHeaders });
|
||||
}
|
||||
} else {
|
||||
console.warn("STRIPE_WEBHOOK_SECRET not set — accepting unverified event");
|
||||
}
|
||||
|
||||
let event: any;
|
||||
try {
|
||||
event = JSON.parse(rawBody);
|
||||
} catch {
|
||||
return new Response("invalid body", { status: 400, headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const type = event.type as string;
|
||||
const obj = event.data?.object || {};
|
||||
const piId: string | undefined =
|
||||
obj.object === "payment_intent" ? obj.id :
|
||||
obj.payment_intent || obj.charge?.payment_intent;
|
||||
|
||||
if (!piId) {
|
||||
console.log("webhook: no payment_intent on event", type);
|
||||
return new Response(JSON.stringify({ ok: true, ignored: "no PI" }), {
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
let { data: payment } = await supabase
|
||||
.from("stripe_payments")
|
||||
.select("*")
|
||||
.eq("stripe_payment_intent_id", piId)
|
||||
.maybeSingle();
|
||||
|
||||
const eventAccount = event.account || obj.account || null;
|
||||
const metadata = obj.metadata || {};
|
||||
|
||||
let mappingQuery = supabase
|
||||
.from("stripe_account_mappings")
|
||||
.select("stripe_secret_key, stripe_account_id, association_id")
|
||||
.eq("is_active", true);
|
||||
|
||||
if (payment?.association_id) {
|
||||
mappingQuery = mappingQuery.eq("association_id", payment.association_id);
|
||||
} else if (metadata.association_id) {
|
||||
mappingQuery = mappingQuery.eq("association_id", metadata.association_id);
|
||||
} else if (eventAccount) {
|
||||
mappingQuery = mappingQuery.eq("stripe_account_id", eventAccount);
|
||||
} else {
|
||||
mappingQuery = mappingQuery.limit(1);
|
||||
}
|
||||
|
||||
const { data: mapping } = await mappingQuery.maybeSingle();
|
||||
if (!mapping?.stripe_secret_key) {
|
||||
console.error("webhook: no stripe key for", { piId, eventAccount, associationId: payment?.association_id || metadata.association_id });
|
||||
return new Response("no stripe key", { status: 400, headers: corsHeaders });
|
||||
}
|
||||
|
||||
const stripeFetch = async (path: string) => {
|
||||
const r = await fetch(`https://api.stripe.com/v1${path}`, {
|
||||
headers: stripeHeaders(mapping.stripe_secret_key, mapping.stripe_account_id),
|
||||
});
|
||||
if (!r.ok) console.error("Stripe fetch failed", path, await r.text());
|
||||
return r.ok ? await r.json() : null;
|
||||
};
|
||||
|
||||
const pi = await stripeFetch(`/payment_intents/${piId}`);
|
||||
const piMetadata = pi?.metadata || metadata;
|
||||
|
||||
if (!payment && pi && (piMetadata.association_id || mapping.association_id)) {
|
||||
const amountCents = Number(piMetadata.net_amount_cents || pi.amount || 0);
|
||||
const feeCents = Number(piMetadata.fee_cents || Math.max(Number(pi.amount || 0) - amountCents, 0));
|
||||
const { data: insertedPayment, error: paymentInsertError } = await supabase
|
||||
.from("stripe_payments")
|
||||
.insert({
|
||||
association_id: piMetadata.association_id || mapping.association_id,
|
||||
owner_id: piMetadata.owner_id || null,
|
||||
unit_id: piMetadata.unit_id || null,
|
||||
stripe_payment_intent_id: piId,
|
||||
amount_cents: amountCents,
|
||||
fee_cents: feeCents,
|
||||
total_cents: Number(pi.amount || amountCents + feeCents),
|
||||
payment_method_type: pi.payment_method_types?.includes("us_bank_account") ? "us_bank_account" : "card",
|
||||
status: pi.status || "pending",
|
||||
description: pi.description || obj.description || "HOA Assessment Payment",
|
||||
})
|
||||
.select("*")
|
||||
.single();
|
||||
|
||||
if (paymentInsertError) throw paymentInsertError;
|
||||
payment = insertedPayment;
|
||||
}
|
||||
|
||||
if (!payment) {
|
||||
console.log("webhook: no stripe_payments row for", piId);
|
||||
return new Response(JSON.stringify({ ok: true, ignored: "unknown PI" }), {
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
const postPaymentToLedger = async (newStatus: string) => {
|
||||
await supabase.from("stripe_payments")
|
||||
.update({ status: newStatus, updated_at: new Date().toISOString() })
|
||||
.eq("id", payment.id);
|
||||
|
||||
const { data: existing } = await supabase
|
||||
.from("owner_ledger_entries")
|
||||
.select("id")
|
||||
.eq("reference_type", "stripe_payment")
|
||||
.eq("reference_id", piId)
|
||||
.maybeSingle();
|
||||
|
||||
if (existing) return { skipped: true };
|
||||
if (!payment.owner_id) return { skipped: "no owner" };
|
||||
|
||||
const netAmount = Number(payment.amount_cents) / 100;
|
||||
const methodLabel = payment.payment_method_type === "us_bank_account" ? "ACH" : "Card";
|
||||
const { error } = await supabase.from("owner_ledger_entries").insert({
|
||||
owner_id: payment.owner_id,
|
||||
association_id: payment.association_id,
|
||||
unit_id: payment.unit_id,
|
||||
date: today,
|
||||
transaction_type: "payment",
|
||||
description: `Online Payment (${methodLabel}) — ${payment.description || "Assessment"}`,
|
||||
debit: 0,
|
||||
credit: netAmount,
|
||||
reference_type: "stripe_payment",
|
||||
reference_id: piId,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return { recorded: true };
|
||||
};
|
||||
|
||||
const reverseLedger = async (refId: string, amount: number, description: string, newStatus: string) => {
|
||||
const { data: existing } = await supabase
|
||||
.from("owner_ledger_entries")
|
||||
.select("id")
|
||||
.eq("reference_type", "stripe_payment_reversal")
|
||||
.eq("reference_id", refId)
|
||||
.maybeSingle();
|
||||
|
||||
if (existing) return { skipped: true };
|
||||
if (!payment.owner_id) return { skipped: "no owner" };
|
||||
|
||||
const { error } = await supabase.from("owner_ledger_entries").insert({
|
||||
owner_id: payment.owner_id,
|
||||
association_id: payment.association_id,
|
||||
unit_id: payment.unit_id,
|
||||
date: today,
|
||||
transaction_type: "adjustment",
|
||||
description,
|
||||
debit: amount,
|
||||
credit: 0,
|
||||
reference_type: "stripe_payment_reversal",
|
||||
reference_id: refId,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
await supabase.from("stripe_payments")
|
||||
.update({ status: newStatus, updated_at: new Date().toISOString() })
|
||||
.eq("id", payment.id);
|
||||
return { recorded: true };
|
||||
};
|
||||
|
||||
if (type === "payment_intent.succeeded" || type === "payment_intent.processing") {
|
||||
if (!pi) return new Response("PI fetch failed", { status: 502, headers: corsHeaders });
|
||||
if (pi.status === "succeeded" || pi.status === "processing") await postPaymentToLedger(pi.status);
|
||||
} else if (type === "charge.refunded") {
|
||||
const charge = await stripeFetch(`/charges/${obj.id}?expand[]=refunds`);
|
||||
if (!charge) return new Response("charge fetch failed", { status: 502, headers: corsHeaders });
|
||||
for (const r of charge.refunds?.data || []) {
|
||||
if (r.status !== "succeeded") continue;
|
||||
await reverseLedger(
|
||||
`${piId}:refund:${r.id}`,
|
||||
Number(r.amount) / 100,
|
||||
`Refund — Stripe payment (${r.id})`,
|
||||
charge.amount_refunded >= charge.amount ? "refunded" : "partially_refunded",
|
||||
);
|
||||
}
|
||||
} else if (type === "payment_intent.payment_failed" || type === "charge.failed") {
|
||||
if (!pi) return new Response("PI fetch failed", { status: 502, headers: corsHeaders });
|
||||
const { data: priorCredit } = await supabase
|
||||
.from("owner_ledger_entries")
|
||||
.select("id, credit")
|
||||
.eq("reference_type", "stripe_payment")
|
||||
.eq("reference_id", piId)
|
||||
.maybeSingle();
|
||||
|
||||
if (priorCredit) {
|
||||
await reverseLedger(
|
||||
`${piId}:failed`,
|
||||
Number(priorCredit.credit) || (Number(payment.amount_cents) / 100),
|
||||
`Returned/Failed Payment — ${pi.last_payment_error?.message || obj.failure_message || "Payment failed / returned"}`,
|
||||
"failed",
|
||||
);
|
||||
} else {
|
||||
await supabase.from("stripe_payments")
|
||||
.update({ status: "failed", updated_at: new Date().toISOString() })
|
||||
.eq("id", payment.id);
|
||||
}
|
||||
} else if (type === "charge.dispute.created") {
|
||||
await reverseLedger(
|
||||
`${piId}:dispute:${obj.id}`,
|
||||
Number(obj.amount || 0) / 100,
|
||||
`Chargeback dispute opened — ${obj.reason || "unspecified"}`,
|
||||
"disputed",
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ ok: true, type }), {
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("stripe-webhook error:", err);
|
||||
return new Response(JSON.stringify({ error: (err as Error).message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.3";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, content-type, stripe-signature",
|
||||
};
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function timingSafeEqual(a: Uint8Array, b: Uint8Array) {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
function hexToBytes(hex: string) {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function verifyStripeSignature(rawBody: string, signatureHeader: string, secret: string) {
|
||||
const parts = signatureHeader.split(",").reduce<Record<string, string[]>>((acc, part) => {
|
||||
const [key, value] = part.split("=");
|
||||
if (!key || !value) return acc;
|
||||
acc[key] = [...(acc[key] || []), value];
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const timestamp = parts.t?.[0];
|
||||
const signatures = parts.v1 || [];
|
||||
if (!timestamp || signatures.length === 0) return false;
|
||||
|
||||
const payload = `${timestamp}.${rawBody}`;
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const expected = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(payload)));
|
||||
return signatures.some((sig) => timingSafeEqual(expected, hexToBytes(sig)));
|
||||
}
|
||||
|
||||
function stripeHeaders(secretKey: string, accountId?: string | null) {
|
||||
return {
|
||||
Authorization: `Bearer ${secretKey}`,
|
||||
...(accountId?.startsWith("acct_") ? { "Stripe-Account": accountId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
|
||||
);
|
||||
|
||||
const webhookSecret = Deno.env.get("STRIPE_WEBHOOK_SECRET");
|
||||
const sig = req.headers.get("stripe-signature");
|
||||
const rawBody = await req.text();
|
||||
|
||||
if (webhookSecret && sig) {
|
||||
const valid = await verifyStripeSignature(rawBody, sig, webhookSecret);
|
||||
if (!valid) {
|
||||
console.error("signature verification failed");
|
||||
return new Response("signature error", { status: 400, headers: corsHeaders });
|
||||
}
|
||||
} else {
|
||||
console.warn("STRIPE_WEBHOOK_SECRET not set — accepting unverified event");
|
||||
}
|
||||
|
||||
let event: any;
|
||||
try {
|
||||
event = JSON.parse(rawBody);
|
||||
} catch {
|
||||
return new Response("invalid body", { status: 400, headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const type = event.type as string;
|
||||
const obj = event.data?.object || {};
|
||||
const piId: string | undefined =
|
||||
obj.object === "payment_intent" ? obj.id :
|
||||
obj.payment_intent || obj.charge?.payment_intent;
|
||||
|
||||
if (!piId) {
|
||||
console.log("webhook: no payment_intent on event", type);
|
||||
return new Response(JSON.stringify({ ok: true, ignored: "no PI" }), {
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
let { data: payment } = await supabase
|
||||
.from("stripe_payments")
|
||||
.select("*")
|
||||
.eq("stripe_payment_intent_id", piId)
|
||||
.maybeSingle();
|
||||
|
||||
const eventAccount = event.account || obj.account || null;
|
||||
const metadata = obj.metadata || {};
|
||||
|
||||
let mappingQuery = supabase
|
||||
.from("stripe_account_mappings")
|
||||
.select("stripe_secret_key, stripe_account_id, association_id")
|
||||
.eq("is_active", true);
|
||||
|
||||
if (payment?.association_id) {
|
||||
mappingQuery = mappingQuery.eq("association_id", payment.association_id);
|
||||
} else if (metadata.association_id) {
|
||||
mappingQuery = mappingQuery.eq("association_id", metadata.association_id);
|
||||
} else if (eventAccount) {
|
||||
mappingQuery = mappingQuery.eq("stripe_account_id", eventAccount);
|
||||
} else {
|
||||
mappingQuery = mappingQuery.limit(1);
|
||||
}
|
||||
|
||||
const { data: mapping } = await mappingQuery.maybeSingle();
|
||||
if (!mapping?.stripe_secret_key) {
|
||||
console.error("webhook: no stripe key for", { piId, eventAccount, associationId: payment?.association_id || metadata.association_id });
|
||||
return new Response("no stripe key", { status: 400, headers: corsHeaders });
|
||||
}
|
||||
|
||||
const stripeFetch = async (path: string) => {
|
||||
const r = await fetch(`https://api.stripe.com/v1${path}`, {
|
||||
headers: stripeHeaders(mapping.stripe_secret_key, mapping.stripe_account_id),
|
||||
});
|
||||
if (!r.ok) console.error("Stripe fetch failed", path, await r.text());
|
||||
return r.ok ? await r.json() : null;
|
||||
};
|
||||
|
||||
const pi = await stripeFetch(`/payment_intents/${piId}`);
|
||||
const piMetadata = pi?.metadata || metadata;
|
||||
|
||||
if (!payment && pi && (piMetadata.association_id || mapping.association_id)) {
|
||||
const amountCents = Number(piMetadata.net_amount_cents || pi.amount || 0);
|
||||
const feeCents = Number(piMetadata.fee_cents || Math.max(Number(pi.amount || 0) - amountCents, 0));
|
||||
const { data: insertedPayment, error: paymentInsertError } = await supabase
|
||||
.from("stripe_payments")
|
||||
.insert({
|
||||
association_id: piMetadata.association_id || mapping.association_id,
|
||||
owner_id: piMetadata.owner_id || null,
|
||||
unit_id: piMetadata.unit_id || null,
|
||||
stripe_payment_intent_id: piId,
|
||||
amount_cents: amountCents,
|
||||
fee_cents: feeCents,
|
||||
total_cents: Number(pi.amount || amountCents + feeCents),
|
||||
payment_method_type: pi.payment_method_types?.includes("us_bank_account") ? "us_bank_account" : "card",
|
||||
status: pi.status || "pending",
|
||||
description: pi.description || obj.description || "HOA Assessment Payment",
|
||||
})
|
||||
.select("*")
|
||||
.single();
|
||||
|
||||
if (paymentInsertError) throw paymentInsertError;
|
||||
payment = insertedPayment;
|
||||
}
|
||||
|
||||
if (!payment) {
|
||||
console.log("webhook: no stripe_payments row for", piId);
|
||||
return new Response(JSON.stringify({ ok: true, ignored: "unknown PI" }), {
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
const postPaymentToLedger = async (newStatus: string) => {
|
||||
await supabase.from("stripe_payments")
|
||||
.update({ status: newStatus, updated_at: new Date().toISOString() })
|
||||
.eq("id", payment.id);
|
||||
|
||||
const { data: existing } = await supabase
|
||||
.from("owner_ledger_entries")
|
||||
.select("id")
|
||||
.eq("reference_type", "stripe_payment")
|
||||
.eq("reference_id", piId)
|
||||
.maybeSingle();
|
||||
|
||||
if (existing) return { skipped: true };
|
||||
if (!payment.owner_id) return { skipped: "no owner" };
|
||||
|
||||
const netAmount = Number(payment.amount_cents) / 100;
|
||||
const methodLabel = payment.payment_method_type === "us_bank_account" ? "ACH" : "Card";
|
||||
const { error } = await supabase.from("owner_ledger_entries").insert({
|
||||
owner_id: payment.owner_id,
|
||||
association_id: payment.association_id,
|
||||
unit_id: payment.unit_id,
|
||||
date: today,
|
||||
transaction_type: "payment",
|
||||
description: `Online Payment (${methodLabel}) — ${payment.description || "Assessment"}`,
|
||||
debit: 0,
|
||||
credit: netAmount,
|
||||
reference_type: "stripe_payment",
|
||||
reference_id: piId,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return { recorded: true };
|
||||
};
|
||||
|
||||
const reverseLedger = async (refId: string, amount: number, description: string, newStatus: string) => {
|
||||
const { data: existing } = await supabase
|
||||
.from("owner_ledger_entries")
|
||||
.select("id")
|
||||
.eq("reference_type", "stripe_payment_reversal")
|
||||
.eq("reference_id", refId)
|
||||
.maybeSingle();
|
||||
|
||||
if (existing) return { skipped: true };
|
||||
if (!payment.owner_id) return { skipped: "no owner" };
|
||||
|
||||
const { error } = await supabase.from("owner_ledger_entries").insert({
|
||||
owner_id: payment.owner_id,
|
||||
association_id: payment.association_id,
|
||||
unit_id: payment.unit_id,
|
||||
date: today,
|
||||
transaction_type: "adjustment",
|
||||
description,
|
||||
debit: amount,
|
||||
credit: 0,
|
||||
reference_type: "stripe_payment_reversal",
|
||||
reference_id: refId,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
await supabase.from("stripe_payments")
|
||||
.update({ status: newStatus, updated_at: new Date().toISOString() })
|
||||
.eq("id", payment.id);
|
||||
return { recorded: true };
|
||||
};
|
||||
|
||||
if (type === "payment_intent.succeeded" || type === "payment_intent.processing") {
|
||||
if (!pi) return new Response("PI fetch failed", { status: 502, headers: corsHeaders });
|
||||
if (pi.status === "succeeded" || pi.status === "processing") await postPaymentToLedger(pi.status);
|
||||
} else if (type === "charge.refunded") {
|
||||
const charge = await stripeFetch(`/charges/${obj.id}?expand[]=refunds`);
|
||||
if (!charge) return new Response("charge fetch failed", { status: 502, headers: corsHeaders });
|
||||
for (const r of charge.refunds?.data || []) {
|
||||
if (r.status !== "succeeded") continue;
|
||||
await reverseLedger(
|
||||
`${piId}:refund:${r.id}`,
|
||||
Number(r.amount) / 100,
|
||||
`Refund — Stripe payment (${r.id})`,
|
||||
charge.amount_refunded >= charge.amount ? "refunded" : "partially_refunded",
|
||||
);
|
||||
}
|
||||
} else if (type === "payment_intent.payment_failed" || type === "charge.failed") {
|
||||
if (!pi) return new Response("PI fetch failed", { status: 502, headers: corsHeaders });
|
||||
const { data: priorCredit } = await supabase
|
||||
.from("owner_ledger_entries")
|
||||
.select("id, credit")
|
||||
.eq("reference_type", "stripe_payment")
|
||||
.eq("reference_id", piId)
|
||||
.maybeSingle();
|
||||
|
||||
if (priorCredit) {
|
||||
await reverseLedger(
|
||||
`${piId}:failed`,
|
||||
Number(priorCredit.credit) || (Number(payment.amount_cents) / 100),
|
||||
`Returned/Failed Payment — ${pi.last_payment_error?.message || obj.failure_message || "Payment failed / returned"}`,
|
||||
"failed",
|
||||
);
|
||||
} else {
|
||||
await supabase.from("stripe_payments")
|
||||
.update({ status: "failed", updated_at: new Date().toISOString() })
|
||||
.eq("id", payment.id);
|
||||
}
|
||||
} else if (type === "charge.dispute.created") {
|
||||
await reverseLedger(
|
||||
`${piId}:dispute:${obj.id}`,
|
||||
Number(obj.amount || 0) / 100,
|
||||
`Chargeback dispute opened — ${obj.reason || "unspecified"}`,
|
||||
"disputed",
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ ok: true, type }), {
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("stripe-webhook error:", err);
|
||||
return new Response(JSON.stringify({ error: (err as Error).message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.99.1";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const { documentId, fileUrl, title } = await req.json();
|
||||
if (!documentId || !fileUrl) {
|
||||
return new Response(JSON.stringify({ error: "documentId and fileUrl are required" }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY");
|
||||
if (!LOVABLE_API_KEY) {
|
||||
return new Response(JSON.stringify({ error: "LOVABLE_API_KEY not configured" }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch the file content
|
||||
let fileContent = "";
|
||||
try {
|
||||
const fileResp = await fetch(fileUrl);
|
||||
if (!fileResp.ok) throw new Error(`Failed to fetch file: ${fileResp.status}`);
|
||||
|
||||
const contentType = fileResp.headers.get("content-type") || "";
|
||||
|
||||
if (contentType.includes("application/pdf")) {
|
||||
// For PDFs, extract text using base64 and Gemini's multimodal capability
|
||||
const arrayBuffer = await fileResp.arrayBuffer();
|
||||
const bytes = new Uint8Array(arrayBuffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
const base64 = btoa(binary);
|
||||
|
||||
const aiResp = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${LOVABLE_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "google/gemini-2.5-flash",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You are a document summarizer for a property management company. Generate a concise 2-4 sentence executive summary of the uploaded document. Focus on key findings, financial figures, action items, and important dates. Be professional and factual.`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Please summarize this management report document titled "${title || 'Untitled'}".`,
|
||||
},
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:application/pdf;base64,${base64}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!aiResp.ok) {
|
||||
if (aiResp.status === 429) {
|
||||
return new Response(JSON.stringify({ error: "Rate limit exceeded, please try again later." }), {
|
||||
status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (aiResp.status === 402) {
|
||||
return new Response(JSON.stringify({ error: "AI credits exhausted. Please add funds." }), {
|
||||
status: 402, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
const errText = await aiResp.text();
|
||||
console.error("AI error:", aiResp.status, errText);
|
||||
throw new Error(`AI gateway error: ${aiResp.status}`);
|
||||
}
|
||||
|
||||
const aiData = await aiResp.json();
|
||||
fileContent = aiData.choices?.[0]?.message?.content || "";
|
||||
} else {
|
||||
// For text-based files, read as text
|
||||
const textContent = await fileResp.text();
|
||||
const truncated = textContent.substring(0, 15000);
|
||||
|
||||
const aiResp = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${LOVABLE_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "google/gemini-2.5-flash",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You are a document summarizer for a property management company. Generate a concise 2-4 sentence executive summary. Focus on key findings, financial figures, action items, and important dates. Be professional and factual.`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `Please summarize this management report titled "${title || 'Untitled'}":\n\n${truncated}`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!aiResp.ok) {
|
||||
if (aiResp.status === 429) {
|
||||
return new Response(JSON.stringify({ error: "Rate limit exceeded." }), {
|
||||
status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (aiResp.status === 402) {
|
||||
return new Response(JSON.stringify({ error: "AI credits exhausted." }), {
|
||||
status: 402, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
throw new Error(`AI gateway error: ${aiResp.status}`);
|
||||
}
|
||||
|
||||
const aiData = await aiResp.json();
|
||||
fileContent = aiData.choices?.[0]?.message?.content || "";
|
||||
}
|
||||
} catch (fetchErr) {
|
||||
console.error("File fetch/AI error:", fetchErr);
|
||||
return new Response(JSON.stringify({ error: "Failed to process document for summary" }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Save summary to database
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from("documents")
|
||||
.update({ ai_summary: fileContent })
|
||||
.eq("id", documentId);
|
||||
|
||||
if (updateError) {
|
||||
console.error("DB update error:", updateError);
|
||||
return new Response(JSON.stringify({ error: "Failed to save summary" }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ summary: fileContent }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("summarize-document error:", e);
|
||||
return new Response(JSON.stringify({ error: e instanceof Error ? e.message : "Unknown error" }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function extractUserIdFromAuthHeader(authHeader: string | null) {
|
||||
if (!authHeader?.startsWith("Bearer ")) return null;
|
||||
|
||||
try {
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
const payload = token.split(".")[1];
|
||||
if (!payload) return null;
|
||||
|
||||
const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
||||
const decoded = JSON.parse(atob(padded));
|
||||
|
||||
return typeof decoded?.sub === "string" ? decoded.sub : null;
|
||||
} catch (error) {
|
||||
console.error("sync-google-calendar token decode failed", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mapGoogleCalendarError(error: any) {
|
||||
const reason = error?.details?.find?.((detail: any) => detail?.reason)?.reason
|
||||
?? error?.errors?.[0]?.reason;
|
||||
const message = error?.message || "Google Calendar request failed.";
|
||||
|
||||
if (reason === "ACCESS_TOKEN_SCOPE_INSUFFICIENT" || reason === "insufficientPermissions") {
|
||||
return {
|
||||
error: "Your Google connection needs to be refreshed for Calendar access. Disconnect Google and connect it again.",
|
||||
code: "RECONNECT_REQUIRED",
|
||||
reconnectRequired: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (reason === "SERVICE_DISABLED" || /calendar api has not been used|calendar api.*disabled/i.test(message)) {
|
||||
return {
|
||||
error: "Google Calendar API is not enabled in the connected Google project.",
|
||||
code: "API_DISABLED",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
error: message,
|
||||
code: "GOOGLE_API_ERROR",
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshAccessToken(serviceClient: any, userId: string, refreshToken: string, clientId: string, clientSecret: string) {
|
||||
const res = await fetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: "refresh_token",
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error_description || data.error);
|
||||
|
||||
const expiresAt = new Date(Date.now() + data.expires_in * 1000).toISOString();
|
||||
await serviceClient
|
||||
.from("google_drive_tokens")
|
||||
.update({
|
||||
access_token: data.access_token,
|
||||
token_expires_at: expiresAt,
|
||||
})
|
||||
.eq("user_id", userId);
|
||||
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const GOOGLE_CLIENT_ID = Deno.env.get("GOOGLE_CLIENT_ID");
|
||||
const GOOGLE_CLIENT_SECRET = Deno.env.get("GOOGLE_CLIENT_SECRET");
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
||||
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
|
||||
if (!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) {
|
||||
return jsonResponse({
|
||||
error: "Google Calendar integration is not configured on the server.",
|
||||
code: "MISSING_CREDENTIALS",
|
||||
});
|
||||
}
|
||||
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
const userId = extractUserIdFromAuthHeader(authHeader);
|
||||
if (!userId) {
|
||||
return jsonResponse({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const serviceClient = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);
|
||||
|
||||
const { data: roles } = await serviceClient
|
||||
.from("user_roles")
|
||||
.select("role")
|
||||
.eq("user_id", userId);
|
||||
|
||||
const isAdmin = roles?.some((role: any) => role.role === "admin" || role.role === "manager");
|
||||
if (!isAdmin) {
|
||||
return jsonResponse({ error: "Only admins can sync Google Calendar" }, 403);
|
||||
}
|
||||
|
||||
const { data: tokenRow } = await serviceClient
|
||||
.from("google_drive_tokens")
|
||||
.select("*")
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (!tokenRow) {
|
||||
return jsonResponse({
|
||||
error: "Google account not connected. Connect Google first.",
|
||||
code: "NOT_CONNECTED",
|
||||
});
|
||||
}
|
||||
|
||||
let accessToken = tokenRow.access_token;
|
||||
const expiresAt = new Date(tokenRow.token_expires_at);
|
||||
if (expiresAt <= new Date()) {
|
||||
if (!tokenRow.refresh_token) {
|
||||
return jsonResponse({
|
||||
error: "Your Google connection expired. Disconnect Google and connect it again.",
|
||||
code: "RECONNECT_REQUIRED",
|
||||
reconnectRequired: true,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
accessToken = await refreshAccessToken(
|
||||
serviceClient,
|
||||
userId,
|
||||
tokenRow.refresh_token,
|
||||
GOOGLE_CLIENT_ID,
|
||||
GOOGLE_CLIENT_SECRET,
|
||||
);
|
||||
} catch (error: any) {
|
||||
return jsonResponse({
|
||||
error: "Your Google connection could not be refreshed. Disconnect Google and connect it again.",
|
||||
code: "RECONNECT_REQUIRED",
|
||||
reconnectRequired: true,
|
||||
details: error?.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { action, calendarIds } = body;
|
||||
|
||||
if (action === "list_calendars") {
|
||||
const calRes = await fetch("https://www.googleapis.com/calendar/v3/users/me/calendarList", {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
const calData = await calRes.json();
|
||||
|
||||
if (calData.error) {
|
||||
return jsonResponse(mapGoogleCalendarError(calData.error));
|
||||
}
|
||||
|
||||
return jsonResponse({ calendars: calData.items || [] });
|
||||
}
|
||||
|
||||
if (action === "sync") {
|
||||
const { data: assocs } = await serviceClient
|
||||
.from("associations")
|
||||
.select("id")
|
||||
.eq("status", "active")
|
||||
.limit(1);
|
||||
|
||||
if (!assocs?.length) {
|
||||
return jsonResponse({ error: "No active association found" }, 400);
|
||||
}
|
||||
|
||||
const defaultAssocId = assocs[0].id;
|
||||
const calendarsToSync = calendarIds || ["primary"];
|
||||
|
||||
const timeMin = new Date();
|
||||
timeMin.setMonth(timeMin.getMonth() - 1);
|
||||
const timeMax = new Date();
|
||||
timeMax.setMonth(timeMax.getMonth() + 6);
|
||||
|
||||
const allEvents: any[] = [];
|
||||
|
||||
for (const calId of calendarsToSync) {
|
||||
const params = new URLSearchParams({
|
||||
timeMin: timeMin.toISOString(),
|
||||
timeMax: timeMax.toISOString(),
|
||||
singleEvents: "true",
|
||||
orderBy: "startTime",
|
||||
maxResults: "250",
|
||||
});
|
||||
|
||||
const evtRes = await fetch(
|
||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calId)}/events?${params}`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
||||
);
|
||||
const evtData = await evtRes.json();
|
||||
|
||||
if (evtData.error) {
|
||||
console.error(`Error fetching calendar ${calId}:`, evtData.error);
|
||||
return jsonResponse(mapGoogleCalendarError(evtData.error));
|
||||
}
|
||||
|
||||
const events = (evtData.items || []).filter((event: any) => event.status !== "cancelled");
|
||||
for (const event of events) {
|
||||
const startDate = event.start?.dateTime || event.start?.date || null;
|
||||
const endDate = event.end?.dateTime || event.end?.date || null;
|
||||
if (!startDate) continue;
|
||||
|
||||
allEvents.push({
|
||||
title: event.summary || "Untitled",
|
||||
description: event.description || null,
|
||||
start_date: startDate,
|
||||
end_date: endDate || startDate,
|
||||
location: event.location || null,
|
||||
event_type: "event",
|
||||
all_day: !event.start?.dateTime,
|
||||
association_id: defaultAssocId,
|
||||
created_by: userId,
|
||||
visibility: ["admin"],
|
||||
is_blocked: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (allEvents.length > 0) {
|
||||
await serviceClient
|
||||
.from("calendar_events")
|
||||
.delete()
|
||||
.eq("created_by", userId)
|
||||
.eq("event_type", "google_sync");
|
||||
|
||||
const rows = allEvents.map((event) => ({ ...event, event_type: "google_sync" }));
|
||||
const { error: insertError } = await serviceClient.from("calendar_events").insert(rows);
|
||||
|
||||
if (insertError) {
|
||||
console.error("Insert error:", insertError);
|
||||
return jsonResponse({ error: `Failed to save events: ${insertError.message}` }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
return jsonResponse({ count: allEvents.length });
|
||||
}
|
||||
|
||||
return jsonResponse({ error: "Unknown action" }, 400);
|
||||
} catch (err: any) {
|
||||
console.error("sync-google-calendar error:", err);
|
||||
return jsonResponse({ error: err.message }, 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function pick<T = unknown>(obj: Record<string, any>, keys: string[]): T | null {
|
||||
for (const k of keys) {
|
||||
if (obj[k] !== undefined && obj[k] !== null && obj[k] !== "") return obj[k] as T;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildTitle(row: Record<string, any>): string {
|
||||
const explicit = pick<string>(row, ["title", "subject", "form_name", "form_title", "type"]);
|
||||
if (explicit) return String(explicit);
|
||||
const name = pick<string>(row, ["submitter_name", "name", "full_name", "from_name"]);
|
||||
return name ? `Submission from ${name}` : "New submission";
|
||||
}
|
||||
|
||||
function buildSummary(row: Record<string, any>): string | null {
|
||||
const summary = pick<string>(row, ["summary", "message", "description", "body", "notes"]);
|
||||
if (summary) return String(summary).slice(0, 2000);
|
||||
// Fallback: stringify the payload field if present
|
||||
const payload = row.data ?? row.payload ?? row.fields ?? row.submission_data;
|
||||
if (payload && typeof payload === "object") {
|
||||
try {
|
||||
return JSON.stringify(payload).slice(0, 2000);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
|
||||
|
||||
try {
|
||||
const targetUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const targetKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const sourceUrl = Deno.env.get("SOURCE_SUPABASE_URL");
|
||||
const sourceKey = Deno.env.get("SOURCE_SUPABASE_SERVICE_ROLE_KEY");
|
||||
|
||||
if (!sourceUrl || !sourceKey) {
|
||||
return jsonResponse({ success: false, error: "SOURCE_SUPABASE_URL / SOURCE_SUPABASE_SERVICE_ROLE_KEY not configured" }, 400);
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const action = String(body.action || "sync");
|
||||
const sourceTable = String(body.source_table || "form_submissions");
|
||||
const limit = Math.min(Number(body.limit || 500), 1000);
|
||||
|
||||
const source = createClient(sourceUrl, sourceKey);
|
||||
const target = createClient(targetUrl, targetKey);
|
||||
|
||||
// Probe: just return one row + columns so we can see the schema.
|
||||
if (action === "probe") {
|
||||
const { data, error } = await source.from(sourceTable).select("*").limit(3);
|
||||
if (error) return jsonResponse({ success: false, error: error.message }, 500);
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
sample: data,
|
||||
columns: data && data.length > 0 ? Object.keys(data[0]) : [],
|
||||
});
|
||||
}
|
||||
|
||||
// Determine high-water mark from existing inbox rows for this source
|
||||
const { data: lastRow } = await target
|
||||
.from("form_inbox")
|
||||
.select("created_at")
|
||||
.eq("source_type", "external_form")
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(1);
|
||||
const since = body.since || lastRow?.[0]?.created_at || null;
|
||||
|
||||
let query = source.from(sourceTable).select("*").order("created_at", { ascending: true }).limit(limit);
|
||||
if (since) query = query.gt("created_at", since);
|
||||
const { data: rows, error } = await query;
|
||||
if (error) return jsonResponse({ success: false, error: error.message }, 500);
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
return jsonResponse({ success: true, inserted: 0, skipped: 0, since });
|
||||
}
|
||||
|
||||
// Dedupe against existing source_ids
|
||||
const sourceIds = rows.map((r: any) => r.id).filter(Boolean);
|
||||
const { data: existing } = await target
|
||||
.from("form_inbox")
|
||||
.select("source_id")
|
||||
.eq("source_type", "external_form")
|
||||
.in("source_id", sourceIds);
|
||||
const existingSet = new Set((existing || []).map((r: any) => r.source_id));
|
||||
|
||||
const toInsert = rows
|
||||
.filter((r: any) => r.id && !existingSet.has(r.id))
|
||||
.map((r: any) => ({
|
||||
source_type: "external_form",
|
||||
source_id: r.id,
|
||||
title: buildTitle(r),
|
||||
submitter_name: pick<string>(r, ["submitter_name", "name", "full_name", "from_name"]),
|
||||
submitter_email: pick<string>(r, ["submitter_email", "email", "from_email", "contact_email"]),
|
||||
summary: buildSummary(r),
|
||||
status: "new",
|
||||
created_at: r.created_at || new Date().toISOString(),
|
||||
}));
|
||||
|
||||
if (toInsert.length === 0) {
|
||||
return jsonResponse({ success: true, inserted: 0, skipped: rows.length, since });
|
||||
}
|
||||
|
||||
const { error: insErr } = await target.from("form_inbox").insert(toInsert);
|
||||
if (insErr) return jsonResponse({ success: false, error: insErr.message }, 500);
|
||||
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
inserted: toInsert.length,
|
||||
skipped: rows.length - toInsert.length,
|
||||
since,
|
||||
latest: rows[rows.length - 1]?.created_at,
|
||||
});
|
||||
} catch (e) {
|
||||
return jsonResponse({ success: false, error: e instanceof Error ? e.message : String(e) }, 500);
|
||||
}
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user