Merge pull request #1 from FranP-code/copilot/fix-c03dd869-9081-4f5e-a7cf-c61635f87fb6

This commit is contained in:
Francisco Pessano
2025-09-01 15:58:27 -03:00
committed by GitHub
21 changed files with 1455 additions and 700 deletions

View File

@@ -1,13 +1,19 @@
# Environment variables for Inbox Negotiator
# Supabase Configuration
SUPABASE_URL=your_supabase_url_here
SUPABASE_ANON_KEY=your_supabase_anon_key_here
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key_here
# Appwrite Configuration
PUBLIC_APPWRITE_ENDPOINT=your_appwrite_endpoint_here
PUBLIC_APPWRITE_PROJECT_ID=your_appwrite_project_id_here
PUBLIC_APPWRITE_DATABASE_ID=your_appwrite_database_id_here
APPWRITE_API_KEY=your_appwrite_api_key_here
# Legacy Supabase Configuration (for migration reference)
# SUPABASE_URL=your_supabase_url_here
# SUPABASE_ANON_KEY=your_supabase_anon_key_here
# SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key_here
# Google Generative AI API Key for Gemini model
GOOGLE_GENERATIVE_AI_API_KEY=your_google_api_key_here
# Add these same variables to your actual .env file
# Note: The SUPABASE_SERVICE_ROLE_KEY is required for webhook operations
# to bypass Row Level Security (RLS) policies in server-side contexts
# Note: The APPWRITE_API_KEY is required for server-side operations
# and webhook operations with admin privileges

View File

@@ -0,0 +1,60 @@
name: Deploy Appwrite Functions
on:
push:
branches: [main]
paths:
- 'appwrite/functions/**'
- 'supabase/functions/**'
workflow_dispatch:
jobs:
deploy-functions:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: latest
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Appwrite CLI
run: npm install -g appwrite-cli
- name: Setup Appwrite CLI
run: |
appwrite client \
--endpoint ${{ secrets.APPWRITE_ENDPOINT }} \
--project-id ${{ secrets.APPWRITE_PROJECT_ID }} \
--key ${{ secrets.APPWRITE_API_KEY }}
- name: Deploy Functions
run: ./scripts/deploy-appwrite-functions.sh
env:
APPWRITE_PROJECT_ID: ${{ secrets.APPWRITE_PROJECT_ID }}
APPWRITE_API_KEY: ${{ secrets.APPWRITE_API_KEY }}
APPWRITE_ENDPOINT: ${{ secrets.APPWRITE_ENDPOINT }}
GOOGLE_AI_API_KEY: ${{ secrets.GOOGLE_AI_API_KEY }}
POSTMARK_SERVER_TOKEN: ${{ secrets.POSTMARK_SERVER_TOKEN }}
- name: Create deployment summary
run: |
echo "## 🚀 Function Deployment Summary" >> $GITHUB_STEP_SUMMARY
echo "Deployed at: $(date)" >> $GITHUB_STEP_SUMMARY
echo "### Functions deployed:" >> $GITHUB_STEP_SUMMARY
for func in appwrite/functions/*/; do
if [ -d "$func" ]; then
echo "- $(basename "$func")" >> $GITHUB_STEP_SUMMARY
fi
done

3
.npmrc Normal file
View File

@@ -0,0 +1,3 @@
# Use pnpm as the package manager for this project
# This ensures consistent dependency management across all environments
package-manager=pnpm

154
APPWRITE_MIGRATION.md Normal file
View File

@@ -0,0 +1,154 @@
# Appwrite Migration Progress
## Completed Migrations
### 1. Dependencies
- ✅ Installed Appwrite SDK v16.0.2
- ✅ Removed @supabase/supabase-js dependency
- ✅ Updated package.json
### 2. Core Configuration
- ✅ Created `src/lib/appwrite.ts` - Main client configuration
- ✅ Created `src/lib/appwrite-admin.ts` - Server-side admin operations
- ✅ Updated environment variables in `.env.example`
### 3. Authentication Components
-`src/components/AuthForm.tsx` - Migrated to Appwrite Account API
-`src/components/AuthGuard.tsx` - Updated session management
-`src/components/Navbar.tsx` - Updated user state handling
### 4. Database Operations
-`src/components/Dashboard.tsx` - Partially migrated database queries
-`src/pages/api/postmark.ts` - Migrated webhook API to use Appwrite
-`src/components/DebtCard.tsx` - Migrated debt operations and function calls
- 🔄 `src/components/Configuration.tsx` - Partially migrated user data fetching
### 5. Function Calls
- ✅ Updated function invocation from Supabase to Appwrite Functions API
- ✅ Changed authentication headers from Bearer tokens to X-Appwrite-Project/X-Appwrite-Key
## Required Appwrite Setup
### Environment Variables
```bash
PUBLIC_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
PUBLIC_APPWRITE_PROJECT_ID=your_project_id
PUBLIC_APPWRITE_DATABASE_ID=your_database_id
APPWRITE_API_KEY=your_api_key
```
### Auto-Deploy Functions Setup
#### 1. Install Appwrite CLI
```bash
npm install -g appwrite-cli
```
#### 2. Initialize Appwrite Project
```bash
pnpm run setup:appwrite
# or manually: appwrite init project --project-id your_project_id
```
#### 3. Deploy Functions Automatically
```bash
# Deploy all functions to Appwrite
pnpm run deploy:functions
# Or run the script directly
./scripts/deploy-appwrite-functions.sh
```
#### 4. GitHub Actions Auto-Deploy
The repository includes a GitHub Actions workflow (`.github/workflows/deploy-appwrite-functions.yml`) that automatically deploys functions when:
- Changes are pushed to the `main` branch
- Files in `appwrite/functions/` or `supabase/functions/` are modified
- Manually triggered via workflow dispatch
**Required GitHub Secrets:**
- `APPWRITE_PROJECT_ID` - Your Appwrite project ID
- `APPWRITE_API_KEY` - Your Appwrite API key with Functions write permissions
- `APPWRITE_ENDPOINT` - Your Appwrite endpoint (default: https://cloud.appwrite.io/v1)
- `GOOGLE_AI_API_KEY` - For AI-powered functions
- `POSTMARK_SERVER_TOKEN` - For email functions
### Database Collections to Create
1. **debts** - Main debt records
2. **audit_logs** - Action logging
3. **user_profiles** - User settings and preferences
4. **additional_emails** - Secondary email addresses
5. **email_processing_usage** - Monthly usage tracking
6. **debt_variables** - Custom debt variables
7. **conversation_messages** - Email conversation history
8. **users** - User personal data
### Functions to Migrate
1. **negotiate** - AI debt negotiation logic
2. **approve-debt** - Debt approval workflow
3. **send-email** - Email sending functionality
4. **analyze-response** - Email response analysis
5. **test-extraction** - Debt information extraction testing
## Remaining Tasks
### Components Not Yet Migrated
- `src/components/ConversationTimeline.tsx`
- `src/components/OnboardingDialog.tsx`
- `src/components/ManualResponseDialog.tsx`
- `src/components/DebtTimeline.tsx`
- `src/components/RealtimeTestButton.tsx`
- `src/components/ExtractionTester.tsx`
### Migration Notes
#### Authentication
- Replaced `supabase.auth.signUp()` with `account.create()` + `account.createEmailPasswordSession()`
- Replaced `supabase.auth.signInWithPassword()` with `account.createEmailPasswordSession()`
- Replaced `supabase.auth.getUser()` with `account.get()`
- Replaced `supabase.auth.signOut()` with `account.deleteSession('current')`
#### Database Operations
- Replaced `supabase.from().select()` with `databases.listDocuments()`
- Replaced `supabase.from().insert()` with `databases.createDocument()`
- Replaced `supabase.from().update()` with `databases.updateDocument()`
- Note: Appwrite queries use different syntax than Supabase filters
#### Function Calls
- Replaced `supabase.functions.invoke()` with `functions.createExecution()`
- Changed authentication from Authorization header to X-Appwrite-Project/X-Appwrite-Key headers
#### Real-time Updates
- Supabase real-time subscriptions need to be replaced with Appwrite real-time
- Currently using polling as a temporary fallback
## Testing Required
1. **Authentication Flow**
- User registration
- User login/logout
- Session persistence
2. **Database Operations**
- Debt creation and updates
- User profile management
- Audit logging
3. **Function Execution**
- Email sending
- AI negotiation
- Response analysis
4. **API Endpoints**
- Postmark webhook processing
- Email parsing and storage
## Production Deployment Checklist
1. Set up Appwrite project and database
2. Create all required collections with proper schemas
3. Deploy Appwrite Functions (migrated from Supabase Edge Functions)
4. Configure environment variables
5. Set up proper permissions and security rules
6. Test all functionality end-to-end
7. Migrate data from Supabase to Appwrite
8. Update DNS/domain configuration if needed

View File

@@ -7,17 +7,48 @@ An AI-powered system that automatically negotiates debt collections and billing
- **AI Email Processing**: Automatically parses incoming emails to extract debt information using Google's Gemini AI
- **Automated Negotiation**: Triggers negotiation workflows for legitimate debt collection notices
- **Webhook Integration**: Seamlessly processes emails through Postmark webhook integration
- **Row Level Security**: Secure database operations with proper authentication handling
- **Secure Database Operations**: Uses Appwrite's document-level permissions for secure data access
## Development Setup
This project uses **pnpm** as the package manager. Make sure you have pnpm installed:
```bash
npm install -g pnpm
```
### Installation
```bash
# Clone the repository
git clone <repository-url>
cd inbox-negotiator
# Install dependencies
pnpm install
# Start development server
pnpm run dev
```
### Available Scripts
- `pnpm run dev` - Start development server
- `pnpm run build` - Build for production
- `pnpm run preview` - Preview production build
- `pnpm run deploy:functions` - Deploy Appwrite functions
- `pnpm run setup:appwrite` - Initialize Appwrite project
## Environment Setup
Copy `.env.example` to `.env` and configure the following variables:
```bash
# Supabase Configuration
SUPABASE_URL=your_supabase_url_here
SUPABASE_ANON_KEY=your_supabase_anon_key_here
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key_here
# Appwrite Configuration
PUBLIC_APPWRITE_ENDPOINT=your_appwrite_endpoint_here
PUBLIC_APPWRITE_PROJECT_ID=your_appwrite_project_id_here
PUBLIC_APPWRITE_DATABASE_ID=your_appwrite_database_id_here
APPWRITE_API_KEY=your_appwrite_api_key_here
# Google Generative AI API Key for Gemini model
GOOGLE_GENERATIVE_AI_API_KEY=your_google_api_key_here
@@ -25,11 +56,23 @@ GOOGLE_GENERATIVE_AI_API_KEY=your_google_api_key_here
### Required Environment Variables
- `SUPABASE_URL`: Your Supabase project URL
- `SUPABASE_ANON_KEY`: Supabase anonymous key for client-side operations
- `SUPABASE_SERVICE_ROLE_KEY`: Supabase service role key for server-side operations (bypasses RLS)
- `PUBLIC_APPWRITE_ENDPOINT`: Your Appwrite instance endpoint (e.g., https://cloud.appwrite.io/v1)
- `PUBLIC_APPWRITE_PROJECT_ID`: Appwrite project ID
- `PUBLIC_APPWRITE_DATABASE_ID`: Appwrite database ID for the application
- `APPWRITE_API_KEY`: Appwrite API key for server-side operations (webhooks, functions)
- `GOOGLE_GENERATIVE_AI_API_KEY`: Google API key for AI processing
## Migration from Supabase
This application has been migrated from Supabase to Appwrite. Key changes include:
- **Authentication**: Migrated from Supabase Auth to Appwrite Account API
- **Database**: Moved from Supabase tables to Appwrite collections
- **Functions**: Migrated from Supabase Edge Functions to Appwrite Functions
- **Real-time**: Updated from Supabase channels to Appwrite real-time subscriptions
For detailed migration notes, see [APPWRITE_MIGRATION.md](./APPWRITE_MIGRATION.md).
## Webhook Configuration
The `/api/postmark` endpoint handles incoming email webhooks from Postmark. It:
@@ -37,23 +80,23 @@ The `/api/postmark` endpoint handles incoming email webhooks from Postmark. It:
1. Validates incoming email data
2. Processes opt-out requests
3. Uses AI to extract debt information
4. Stores processed data in Supabase
4. Stores processed data in Appwrite
5. Triggers automated negotiation workflows
### RLS (Row Level Security) Handling
### Security Handling
The webhook uses a service role client to bypass RLS policies, ensuring server-side operations can write to the database without user authentication. This is essential for webhook operations where no user session exists.
The webhook uses an Appwrite admin client with API key authentication, ensuring server-side operations can write to the database without user authentication. This is essential for webhook operations where no user session exists.
## Development
```bash
# Install dependencies
pnpm install
npm install
# Start development server
pnpm dev
npm run dev
```
## Deployment
Ensure all environment variables are configured in your deployment environment, especially the `SUPABASE_SERVICE_ROLE_KEY` which is critical for webhook operations.
Ensure all environment variables are configured in your deployment environment, especially the `APPWRITE_API_KEY` which is critical for webhook operations.

85
appwrite/appwrite.json Normal file
View File

@@ -0,0 +1,85 @@
{
"projectId": "$APPWRITE_PROJECT_ID",
"projectName": "InboxNegotiator",
"functions": [
{
"name": "negotiate",
"functionId": "negotiate",
"runtime": "node-18.0",
"entrypoint": "src/main.js",
"commands": "npm install",
"timeout": 15,
"enabled": true,
"execute": ["any"],
"events": [],
"schedule": "",
"env": {
"GOOGLE_AI_API_KEY": "$GOOGLE_AI_API_KEY",
"APPWRITE_DATABASE_ID": "$APPWRITE_DATABASE_ID"
}
},
{
"name": "approve-debt",
"functionId": "approve-debt",
"runtime": "node-18.0",
"entrypoint": "src/main.js",
"commands": "npm install",
"timeout": 15,
"enabled": true,
"execute": ["any"],
"events": [],
"schedule": "",
"env": {
"APPWRITE_DATABASE_ID": "$APPWRITE_DATABASE_ID"
}
},
{
"name": "send-email",
"functionId": "send-email",
"runtime": "node-18.0",
"entrypoint": "src/main.js",
"commands": "npm install",
"timeout": 15,
"enabled": true,
"execute": ["any"],
"events": [],
"schedule": "",
"env": {
"POSTMARK_SERVER_TOKEN": "$POSTMARK_SERVER_TOKEN",
"APPWRITE_DATABASE_ID": "$APPWRITE_DATABASE_ID"
}
},
{
"name": "analyze-response",
"functionId": "analyze-response",
"runtime": "node-18.0",
"entrypoint": "src/main.js",
"commands": "npm install",
"timeout": 15,
"enabled": true,
"execute": ["any"],
"events": [],
"schedule": "",
"env": {
"GOOGLE_AI_API_KEY": "$GOOGLE_AI_API_KEY",
"APPWRITE_DATABASE_ID": "$APPWRITE_DATABASE_ID"
}
},
{
"name": "test-extraction",
"functionId": "test-extraction",
"runtime": "node-18.0",
"entrypoint": "src/main.js",
"commands": "npm install",
"timeout": 15,
"enabled": true,
"execute": ["any"],
"events": [],
"schedule": "",
"env": {
"GOOGLE_AI_API_KEY": "$GOOGLE_AI_API_KEY",
"APPWRITE_DATABASE_ID": "$APPWRITE_DATABASE_ID"
}
}
]
}

View File

@@ -8,7 +8,9 @@
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
"astro": "astro",
"deploy:functions": "./scripts/deploy-appwrite-functions.sh",
"setup:appwrite": "appwrite init project"
},
"dependencies": {
"@ai-sdk/google": "^1.2.19",
@@ -44,11 +46,11 @@
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@supabase/supabase-js": "^2.50.0",
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@vercel/analytics": "^1.5.0",
"ai": "^4.3.16",
"appwrite": "^18.2.0",
"astro": "^5.9.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",

101
pnpm-lock.yaml generated
View File

@@ -107,9 +107,6 @@ importers:
'@radix-ui/react-tooltip':
specifier: ^1.1.2
version: 1.2.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@supabase/supabase-js':
specifier: ^2.50.0
version: 2.50.0
'@types/react':
specifier: ^18.3.10
version: 18.3.23
@@ -122,6 +119,9 @@ importers:
ai:
specifier: ^4.3.16
version: 4.3.16(react@18.3.1)(zod@3.23.8)
appwrite:
specifier: ^18.2.0
version: 18.2.0
astro:
specifier: ^5.9.0
version: 5.9.0(@types/node@22.15.30)(jiti@1.21.7)(rollup@4.42.0)(typescript@5.8.3)(yaml@2.8.0)
@@ -1449,28 +1449,6 @@ packages:
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
'@supabase/auth-js@2.70.0':
resolution: {integrity: sha512-BaAK/tOAZFJtzF1sE3gJ2FwTjLf4ky3PSvcvLGEgEmO4BSBkwWKu8l67rLLIBZPDnCyV7Owk2uPyKHa0kj5QGg==}
'@supabase/functions-js@2.4.4':
resolution: {integrity: sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==}
'@supabase/node-fetch@2.6.15':
resolution: {integrity: sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==}
engines: {node: 4.x || >=6.0.0}
'@supabase/postgrest-js@1.19.4':
resolution: {integrity: sha512-O4soKqKtZIW3olqmbXXbKugUtByD2jPa8kL2m2c1oozAO11uCcGrRhkZL0kVxjBLrXHE0mdSkFsMj7jDSfyNpw==}
'@supabase/realtime-js@2.11.10':
resolution: {integrity: sha512-SJKVa7EejnuyfImrbzx+HaD9i6T784khuw1zP+MBD7BmJYChegGxYigPzkKX8CK8nGuDntmeSD3fvriaH0EGZA==}
'@supabase/storage-js@2.7.1':
resolution: {integrity: sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==}
'@supabase/supabase-js@2.50.0':
resolution: {integrity: sha512-M1Gd5tPaaghYZ9OjeO1iORRqbTWFEz/cF3pPubRnMPzA+A8SiUsXXWDP+DWsASZcjEcVEcVQIAF38i5wrijYOg==}
'@swc/helpers@0.5.17':
resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==}
@@ -1543,9 +1521,6 @@ packages:
'@types/node@22.15.30':
resolution: {integrity: sha512-6Q7lr06bEHdlfplU6YRbgG1SFBdlsfNC4/lX+SkhiTs0cpJkOElmWls8PxDFv4yY/xKb8Y6SO0OmSX4wgqTZbA==}
'@types/phoenix@1.6.6':
resolution: {integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==}
'@types/prop-types@15.7.14':
resolution: {integrity: sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==}
@@ -1560,9 +1535,6 @@ packages:
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
@@ -1666,6 +1638,9 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
appwrite@18.2.0:
resolution: {integrity: sha512-g7pQpsxqR7+amEIaQLXMN4XzdQKenTHnGdA4s7UUJdZufhlHdJby8895h8z893+S0XipeHZhi0wpxYA2An95Rg==}
arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
@@ -3343,18 +3318,6 @@ packages:
resolution: {integrity: sha512-GmqrO8WJ1NuzJ2DrziEI2o57jKAVIQNf8a18W3nCYU3H7PNWqCCVTeH6/NQE93CIllIgQS98rrmVkYgTX9fFJQ==}
engines: {node: ^18.17.0 || >=20.5.0}
ws@8.18.2:
resolution: {integrity: sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
xxhash-wasm@1.1.0:
resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==}
@@ -4686,48 +4649,6 @@ snapshots:
'@shikijs/vscode-textmate@10.0.2': {}
'@supabase/auth-js@2.70.0':
dependencies:
'@supabase/node-fetch': 2.6.15
'@supabase/functions-js@2.4.4':
dependencies:
'@supabase/node-fetch': 2.6.15
'@supabase/node-fetch@2.6.15':
dependencies:
whatwg-url: 5.0.0
'@supabase/postgrest-js@1.19.4':
dependencies:
'@supabase/node-fetch': 2.6.15
'@supabase/realtime-js@2.11.10':
dependencies:
'@supabase/node-fetch': 2.6.15
'@types/phoenix': 1.6.6
'@types/ws': 8.18.1
ws: 8.18.2
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@supabase/storage-js@2.7.1':
dependencies:
'@supabase/node-fetch': 2.6.15
'@supabase/supabase-js@2.50.0':
dependencies:
'@supabase/auth-js': 2.70.0
'@supabase/functions-js': 2.4.4
'@supabase/node-fetch': 2.6.15
'@supabase/postgrest-js': 1.19.4
'@supabase/realtime-js': 2.11.10
'@supabase/storage-js': 2.7.1
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@swc/helpers@0.5.17':
dependencies:
tslib: 2.8.1
@@ -4809,8 +4730,6 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/phoenix@1.6.6': {}
'@types/prop-types@15.7.14': {}
'@types/react-dom@18.3.7(@types/react@18.3.23)':
@@ -4824,10 +4743,6 @@ snapshots:
'@types/unist@3.0.3': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 22.15.30
'@ungap/structured-clone@1.3.0': {}
'@vercel/analytics@1.5.0(react@18.3.1)':
@@ -4925,6 +4840,8 @@ snapshots:
normalize-path: 3.0.0
picomatch: 2.3.1
appwrite@18.2.0: {}
arg@5.0.2: {}
argparse@2.0.1: {}
@@ -6915,8 +6832,6 @@ snapshots:
imurmurhash: 0.1.4
signal-exit: 4.1.0
ws@8.18.2: {}
xxhash-wasm@1.1.0: {}
yallist@3.1.1: {}

View File

@@ -0,0 +1,222 @@
#!/bin/bash
# Appwrite Functions Auto-Deploy Script
# This script migrates Supabase Edge Functions to Appwrite Functions
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Configuration
SUPABASE_FUNCTIONS_DIR="./supabase/functions"
APPWRITE_FUNCTIONS_DIR="./appwrite/functions"
APPWRITE_CONFIG="./appwrite/appwrite.json"
echo -e "${GREEN}🚀 Appwrite Functions Auto-Deploy Script${NC}"
echo "========================================"
# Check if Appwrite CLI is installed
if ! command -v appwrite &> /dev/null; then
echo -e "${RED}❌ Appwrite CLI not found${NC}"
echo "Please install it with: npm install -g appwrite-cli"
exit 1
fi
# Check environment variables
if [ -z "$APPWRITE_PROJECT_ID" ]; then
echo -e "${RED}❌ APPWRITE_PROJECT_ID environment variable not set${NC}"
exit 1
fi
if [ -z "$APPWRITE_API_KEY" ]; then
echo -e "${RED}❌ APPWRITE_API_KEY environment variable not set${NC}"
exit 1
fi
echo -e "${YELLOW}📋 Environment Variables:${NC}"
echo "APPWRITE_PROJECT_ID: $APPWRITE_PROJECT_ID"
echo "APPWRITE_API_KEY: ${APPWRITE_API_KEY:0:8}..."
echo ""
# Initialize Appwrite project if not already done
echo -e "${YELLOW}🔧 Setting up Appwrite project...${NC}"
if [ ! -f ".appwrite/project.json" ]; then
appwrite init project --project-id "$APPWRITE_PROJECT_ID"
fi
# Create Appwrite functions directory if it doesn't exist
mkdir -p "$APPWRITE_FUNCTIONS_DIR"
# Function to convert Supabase function to Appwrite function
convert_function() {
local func_name=$1
local supabase_func_dir="$SUPABASE_FUNCTIONS_DIR/$func_name"
local appwrite_func_dir="$APPWRITE_FUNCTIONS_DIR/$func_name"
if [ ! -d "$supabase_func_dir" ]; then
echo -e "${RED}❌ Supabase function '$func_name' not found${NC}"
return 1
fi
echo -e "${YELLOW}🔄 Converting function: $func_name${NC}"
# Create Appwrite function directory
mkdir -p "$appwrite_func_dir/src"
# Copy and convert the main file
if [ -f "$supabase_func_dir/index.ts" ]; then
# Convert Supabase function to Appwrite function format
cat > "$appwrite_func_dir/src/main.js" << 'EOF'
import { Client, Databases, Functions } from 'node-appwrite';
// This is a migrated function from Supabase to Appwrite
// Original Supabase function logic should be adapted here
export default async ({ req, res, log, error }) => {
const client = new Client()
.setEndpoint(process.env.APPWRITE_FUNCTION_ENDPOINT)
.setProject(process.env.APPWRITE_FUNCTION_PROJECT_ID)
.setKey(process.env.APPWRITE_API_KEY);
const databases = new Databases(client);
try {
// TODO: Migrate Supabase function logic here
// Original file: ${supabase_func_dir}/index.ts
log('Function executed successfully');
return res.json({ success: true, message: 'Function migrated from Supabase' });
} catch (err) {
error('Function execution failed: ' + err.message);
return res.json({ success: false, error: err.message }, 500);
}
};
EOF
# Create package.json for the function
cat > "$appwrite_func_dir/package.json" << EOF
{
"name": "$func_name",
"version": "1.0.0",
"description": "Migrated from Supabase Edge Function",
"main": "src/main.js",
"type": "module",
"dependencies": {
"node-appwrite": "^13.0.0"
}
}
EOF
# Create README with migration notes
cat > "$appwrite_func_dir/README.md" << EOF
# $func_name Function
This function was migrated from Supabase Edge Functions to Appwrite Functions.
## Original Supabase Function
- Location: \`$supabase_func_dir/index.ts\`
- Migrated on: $(date)
## Migration Notes
- The function logic needs to be manually adapted from the original Supabase function
- Update environment variables and dependencies as needed
- Test thoroughly before deploying to production
## Deployment
\`\`\`bash
appwrite functions createDeployment --function-id=$func_name --activate=true
\`\`\`
EOF
echo -e "${GREEN}✅ Function '$func_name' converted successfully${NC}"
else
echo -e "${RED}❌ No index.ts found for function '$func_name'${NC}"
return 1
fi
}
# Deploy function to Appwrite
deploy_function() {
local func_name=$1
local appwrite_func_dir="$APPWRITE_FUNCTIONS_DIR/$func_name"
if [ ! -d "$appwrite_func_dir" ]; then
echo -e "${RED}❌ Appwrite function '$func_name' not found${NC}"
return 1
fi
echo -e "${YELLOW}🚀 Deploying function: $func_name${NC}"
# Create function if it doesn't exist
appwrite functions create \
--function-id="$func_name" \
--name="$func_name" \
--runtime="node-18.0" \
--execute='["any"]' \
--timeout=15 \
--enabled=true || echo "Function may already exist, continuing..."
# Deploy the function
cd "$appwrite_func_dir"
appwrite functions createDeployment \
--function-id="$func_name" \
--activate=true \
--code="."
cd - > /dev/null
echo -e "${GREEN}✅ Function '$func_name' deployed successfully${NC}"
}
# Main execution
echo -e "${YELLOW}📋 Available Supabase functions:${NC}"
for func_dir in "$SUPABASE_FUNCTIONS_DIR"/*; do
if [ -d "$func_dir" ]; then
func_name=$(basename "$func_dir")
echo " - $func_name"
fi
done
echo ""
# Convert functions
echo -e "${YELLOW}🔄 Converting Supabase functions to Appwrite format...${NC}"
for func_dir in "$SUPABASE_FUNCTIONS_DIR"/*; do
if [ -d "$func_dir" ]; then
func_name=$(basename "$func_dir")
convert_function "$func_name"
fi
done
echo ""
echo -e "${YELLOW}🚀 Deploying functions to Appwrite...${NC}"
# Ask for confirmation
read -p "Do you want to deploy all functions to Appwrite? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
for func_dir in "$APPWRITE_FUNCTIONS_DIR"/*; do
if [ -d "$func_dir" ]; then
func_name=$(basename "$func_dir")
deploy_function "$func_name"
fi
done
echo ""
echo -e "${GREEN}🎉 All functions deployed successfully!${NC}"
echo ""
echo -e "${YELLOW}📝 Next steps:${NC}"
echo "1. Update each function's logic in appwrite/functions/*/src/main.js"
echo "2. Test functions in Appwrite console"
echo "3. Update your application to use Appwrite function IDs"
echo "4. Set up environment variables for each function"
else
echo -e "${YELLOW}⏭️ Function deployment skipped${NC}"
echo "You can deploy individual functions later using:"
echo " appwrite functions createDeployment --function-id=FUNCTION_NAME --activate=true"
fi
echo ""
echo -e "${GREEN}✨ Migration process completed!${NC}"

View File

@@ -1,11 +1,12 @@
import React, { useState } from 'react';
import { supabase } from '../lib/supabase';
import { account } from '../lib/appwrite';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Loader2, Mail, Lock, User } from 'lucide-react';
import { ID } from 'appwrite';
interface AuthFormProps {
mode: 'login' | 'signup';
@@ -27,32 +28,28 @@ export function AuthForm({ mode }: AuthFormProps) {
try {
if (mode === 'signup') {
const { error } = await supabase.auth.signUp({
// Create account with Appwrite
await account.create(
ID.unique(),
email,
password,
options: {
data: {
full_name: fullName,
}
}
});
fullName
);
if (error) throw error;
setMessage('Check your email for the confirmation link!');
// Create session after account creation
await account.createEmailPasswordSession(email, password);
setMessage('Account created successfully!');
window.location.href = '/dashboard';
} else {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) throw error;
// Sign in with Appwrite
await account.createEmailPasswordSession(email, password);
// Redirect to dashboard on successful login
window.location.href = '/dashboard';
}
} catch (error: any) {
setError(error.message);
setError(error.message || 'An error occurred');
} finally {
setLoading(false);
}

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
import type { User } from '@supabase/supabase-js';
import { account } from '../lib/appwrite';
import type { Models } from 'appwrite';
import { Loader2 } from 'lucide-react';
interface AuthGuardProps {
@@ -9,47 +9,38 @@ interface AuthGuardProps {
}
export function AuthGuard({ children, requireAuth = true }: AuthGuardProps) {
const [user, setUser] = useState<User | null>(null);
const [user, setUser] = useState<Models.User<Models.Preferences> | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
setUser(session?.user ?? null);
account.get().then((currentUser) => {
setUser(currentUser);
setLoading(false);
// Redirect logic
if (requireAuth && !session?.user) {
if (requireAuth && !currentUser) {
// User needs to be authenticated but isn't - redirect to login
window.location.href = '/login';
} else if (!requireAuth && session?.user) {
} else if (!requireAuth && currentUser) {
// User is authenticated but on a public page - redirect to dashboard
const currentPath = window.location.pathname;
if (currentPath === '/login' || currentPath === '/signup') {
window.location.href = '/dashboard';
}
}
}).catch(() => {
// No user session found
setUser(null);
setLoading(false);
if (requireAuth) {
window.location.href = '/login';
}
});
// Listen for auth changes
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, session) => {
setUser(session?.user ?? null);
setLoading(false);
// Handle auth state changes
if (requireAuth && !session?.user) {
window.location.href = '/login';
} else if (!requireAuth && session?.user) {
const currentPath = window.location.pathname;
if (currentPath === '/login' || currentPath === '/signup') {
window.location.href = '/dashboard';
}
}
}
);
return () => subscription.unsubscribe();
// Note: Appwrite doesn't have built-in session listeners like Supabase
// You might need to implement session checking through other means or use Appwrite's real-time features
}, [requireAuth]);
if (loading) {

View File

@@ -1,10 +1,14 @@
import React, { useEffect, useState } from "react";
import {
supabase,
account,
databases,
DATABASE_ID,
COLLECTIONS,
type AdditionalEmail,
type UserProfile,
type EmailProcessingUsage,
} from "../lib/supabase";
} from "../lib/appwrite";
import { ID, Query } from "appwrite";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
@@ -65,44 +69,45 @@ export function Configuration() {
const fetchUserData = async () => {
try {
const {
data: { user },
} = await supabase.auth.getUser();
const user = await account.get();
if (!user) return;
// Fetch user profile
const { data: profileData } = await supabase
.from("user_profiles")
.select("*")
.eq("user_id", user.id)
.single();
const profileResponse = await databases.listDocuments(
DATABASE_ID,
COLLECTIONS.USER_PROFILES,
[Query.equal('user_id', user.$id)]
);
const profileData = profileResponse.documents[0];
// Fetch user personal data
const { data: userData } = await supabase
.from("users")
.select("*")
.eq("id", user.id)
.single();
// Fetch user personal data from users collection
const usersResponse = await databases.listDocuments(
DATABASE_ID,
'users', // Assuming users collection exists
[Query.equal('id', user.$id)]
);
const userData = usersResponse.documents[0];
// Fetch additional emails
const { data: emailsData } = await supabase
.from("additional_emails")
.select("*")
.eq("user_id", user.id)
.order("created_at", { ascending: false });
const emailsResponse = await databases.listDocuments(
DATABASE_ID,
COLLECTIONS.ADDITIONAL_EMAILS,
[Query.equal('user_id', user.$id), Query.orderDesc('created_at')]
);
const emailsData = emailsResponse.documents;
// Fetch current month usage
const currentMonth = new Date().toISOString().slice(0, 7); // YYYY-MM
const { data: usageData } = await supabase
.from("email_processing_usage")
.select("*")
.eq("user_id", user.id)
.eq("month_year", currentMonth)
.single();
const usageResponse = await databases.listDocuments(
DATABASE_ID,
COLLECTIONS.EMAIL_PROCESSING_USAGE,
[Query.equal('user_id', user.$id), Query.equal('month_year', currentMonth)]
);
const usageData = usageResponse.documents[0];
setProfile(profileData);
setAdditionalEmails(emailsData || []);
setUsage(usageData);
setProfile(profileData as UserProfile);
setAdditionalEmails(emailsData as AdditionalEmail[]);
setUsage(usageData as EmailProcessingUsage);
// Set personal data
if (userData) {
@@ -131,25 +136,46 @@ export function Configuration() {
const savePersonalData = async () => {
setSavingPersonalData(true);
try {
const {
data: { user },
} = await supabase.auth.getUser();
const user = await account.get();
if (!user) return;
const { error } = await supabase
.from("users")
.update({
full_name: personalData.full_name || null,
address_line_1: personalData.address_line_1 || null,
address_line_2: personalData.address_line_2 || null,
city: personalData.city || null,
state: personalData.state || null,
zip_code: personalData.zip_code || null,
phone_number: personalData.phone_number || null,
})
.eq("id", user.id);
// First, try to get the existing user document
const usersResponse = await databases.listDocuments(
DATABASE_ID,
'users',
[Query.equal('id', user.$id)]
);
if (error) throw error;
const updateData = {
full_name: personalData.full_name || null,
address_line_1: personalData.address_line_1 || null,
address_line_2: personalData.address_line_2 || null,
city: personalData.city || null,
state: personalData.state || null,
zip_code: personalData.zip_code || null,
phone_number: personalData.phone_number || null,
};
if (usersResponse.documents.length > 0) {
// Update existing document
await databases.updateDocument(
DATABASE_ID,
'users',
usersResponse.documents[0].$id,
updateData
);
} else {
// Create new document if it doesn't exist
await databases.createDocument(
DATABASE_ID,
'users',
ID.unique(),
{
id: user.$id,
...updateData
}
);
}
toast.success("Personal data updated", {
description: "Your personal information has been saved successfully.",
@@ -166,19 +192,42 @@ export function Configuration() {
const saveServerToken = async () => {
setSavingServerToken(true);
try {
const {
data: { user },
} = await supabase.auth.getUser();
const user = await account.get();
if (!user) return;
const { error } = await supabase
.from("user_profiles")
.update({
postmark_server_token: serverToken || null,
})
.eq("user_id", user.id);
// Get the existing user profile document
const profileResponse = await databases.listDocuments(
DATABASE_ID,
COLLECTIONS.USER_PROFILES,
[Query.equal('user_id', user.$id)]
);
if (error) throw error;
if (profileResponse.documents.length > 0) {
// Update existing profile
await databases.updateDocument(
DATABASE_ID,
COLLECTIONS.USER_PROFILES,
profileResponse.documents[0].$id,
{
postmark_server_token: serverToken || null,
}
);
} else {
// Create new profile if it doesn't exist
await databases.createDocument(
DATABASE_ID,
COLLECTIONS.USER_PROFILES,
ID.unique(),
{
user_id: user.$id,
postmark_server_token: serverToken || null,
onboarding_completed: false,
email_processing_limit: 1000,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
);
}
// Update local profile state
setProfile((prev) =>
@@ -202,23 +251,24 @@ export function Configuration() {
setAddingEmail(true);
try {
const {
data: { user },
} = await supabase.auth.getUser();
const user = await account.get();
if (!user) return;
const { data, error } = await supabase
.from("additional_emails")
.insert({
user_id: user.id,
const newEmailDoc = await databases.createDocument(
DATABASE_ID,
COLLECTIONS.ADDITIONAL_EMAILS,
ID.unique(),
{
user_id: user.$id,
email_address: newEmail.trim().toLowerCase(),
})
.select()
.single();
verified: false,
verification_token: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
);
if (error) throw error;
setAdditionalEmails([data, ...additionalEmails]);
setAdditionalEmails([newEmailDoc as AdditionalEmail, ...additionalEmails]);
setNewEmail("");
toast.success("Email added successfully", {
description: "Additional email has been added to your account.",
@@ -234,15 +284,14 @@ export function Configuration() {
const removeAdditionalEmail = async (emailId: string) => {
try {
const { error } = await supabase
.from("additional_emails")
.delete()
.eq("id", emailId);
if (error) throw error;
await databases.deleteDocument(
DATABASE_ID,
COLLECTIONS.ADDITIONAL_EMAILS,
emailId
);
setAdditionalEmails(
additionalEmails.filter((email) => email.id !== emailId)
additionalEmails.filter((email) => email.$id !== emailId)
);
toast.success("Email removed", {
description: "Additional email has been removed from your account.",
@@ -592,7 +641,7 @@ export function Configuration() {
) : (
additionalEmails.map((email) => (
<div
key={email.id}
key={email.$id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex items-center gap-3">
@@ -624,7 +673,7 @@ export function Configuration() {
<Button
variant="ghost"
size="sm"
onClick={() => removeAdditionalEmail(email.id)}
onClick={() => removeAdditionalEmail(email.$id)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />

View File

@@ -1,9 +1,11 @@
import { useEffect, useState } from "react";
import { supabase, type Debt, type UserProfile } from "../lib/supabase";
import { account, databases, DATABASE_ID, COLLECTIONS, type Debt, type UserProfile } from "../lib/appwrite";
import { Query } from "appwrite";
import { Button } from "./ui/button";
import { DebtCard } from "./DebtCard";
import { ConversationTimeline } from "./ConversationTimeline";
import { OnboardingDialog } from "./OnboardingDialog";
// TODO: Migrate these components to Appwrite
// import { ConversationTimeline } from "./ConversationTimeline";
// import { OnboardingDialog } from "./OnboardingDialog";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
import { Badge } from "./ui/badge";
@@ -18,6 +20,7 @@ import {
Settings,
} from "lucide-react";
import { formatCurrency } from "../lib/utils";
import type { Models } from "appwrite";
export function Dashboard() {
const [debts, setDebts] = useState<Debt[]>([]);
@@ -43,18 +46,18 @@ export function Dashboard() {
const fetchUserProfile = async () => {
try {
const {
data: { user },
} = await supabase.auth.getUser();
const user = await account.get();
if (!user) return;
const { data: profile } = await supabase
.from("user_profiles")
.select("*")
.eq("user_id", user.id)
.single();
const response = await databases.listDocuments(
DATABASE_ID,
COLLECTIONS.USER_PROFILES,
[Query.equal('user_id', user.$id)]
);
setUserProfile(profile);
// Get profile for current user
const profile = response.documents[0];
setUserProfile(profile as UserProfile);
// Show onboarding if user hasn't completed it
if (profile && !profile.onboarding_completed) {
@@ -67,19 +70,17 @@ export function Dashboard() {
const fetchDebts = async () => {
try {
const {
data: { user },
} = await supabase.auth.getUser();
const user = await account.get();
if (!user) return;
const { data, error } = await supabase
.from("debts")
.select("*")
.eq("user_id", user.id)
.order("created_at", { ascending: false });
const response = await databases.listDocuments(
DATABASE_ID,
COLLECTIONS.DEBTS,
[Query.equal('user_id', user.$id), Query.orderDesc('created_at')]
);
if (error) throw error;
setDebts(data || []);
// Debts are already filtered and sorted by the query
setDebts(response.documents as Debt[]);
} catch (error) {
console.error("Error fetching debts:", error);
} finally {
@@ -88,36 +89,24 @@ export function Dashboard() {
};
const setupRealtimeSubscription = () => {
const subscription = supabase
.channel("debts_changes")
.on(
"postgres_changes",
{
event: "*",
schema: "public",
table: "debts",
},
(payload) => {
if (payload.eventType === "INSERT") {
setDebts((prev) => [payload.new as Debt, ...prev]);
} else if (payload.eventType === "UPDATE") {
setDebts((prev) =>
prev.map((debt) =>
debt.id === payload.new.id ? (payload.new as Debt) : debt
)
);
} else if (payload.eventType === "DELETE") {
setDebts((prev) =>
prev.filter((debt) => debt.id !== payload.old.id)
);
}
}
)
.subscribe();
// Appwrite real-time subscription for debts collection
// Note: This is a simplified version. In production, you'd need to set up proper channels
// and subscribe to document changes for the specific collection
// For now, we'll implement a polling mechanism as a fallback
// In a full migration, you'd set up Appwrite's real-time listeners
const interval = setInterval(() => {
fetchDebts();
}, 30000); // Poll every 30 seconds
return () => {
subscription.unsubscribe();
clearInterval(interval);
};
// TODO: Implement proper Appwrite real-time subscription
// client.subscribe('databases.${DATABASE_ID}.collections.${COLLECTIONS.DEBTS}.documents', response => {
// // Handle real-time updates
// });
};
const calculateStats = () => {
@@ -149,7 +138,7 @@ export function Dashboard() {
};
const handleSignOut = async () => {
await supabase.auth.signOut();
await account.deleteSession('current');
window.location.href = "/";
};
@@ -336,10 +325,11 @@ export function Dashboard() {
</div>
{/* Onboarding Dialog */}
<OnboardingDialog
{/* TODO: Migrate OnboardingDialog to Appwrite */}
{/* <OnboardingDialog
open={showOnboarding}
onComplete={handleOnboardingComplete}
/>
/> */}
</div>
);
}

View File

@@ -44,7 +44,8 @@ import {
ExternalLink,
Eye,
} from "lucide-react";
import { supabase, type Debt, type DebtVariable } from "../lib/supabase";
import { account, databases, functions, DATABASE_ID, COLLECTIONS, type Debt, type DebtVariable } from "../lib/appwrite";
import { ID, Query } from "appwrite";
import { toast } from "sonner";
import { formatCurrency } from "../lib/utils";
import {
@@ -53,8 +54,9 @@ import {
getVariablesForTemplate,
updateVariablesForTextChange,
} from "../lib/emailVariables";
import { ManualResponseDialog } from "./ManualResponseDialog";
import { ConversationTimeline } from "./ConversationTimeline";
// TODO: Migrate these components to Appwrite
// import { ManualResponseDialog } from "./ManualResponseDialog";
// import { ConversationTimeline } from "./ConversationTimeline";
interface DebtCardProps {
debt: Debt;
@@ -217,18 +219,12 @@ export function DebtCard({ debt, onUpdate, debts, setDebts }: DebtCardProps) {
},
};
const { error } = await supabase
.from("debts")
.update({ metadata: updatedMetadata })
.eq("id", debt.id);
if (error) {
console.error("Error saving debt metadata:", error);
toast.error("Error", {
description: "Failed to save email changes. Please try again.",
});
return;
}
await databases.updateDocument(
DATABASE_ID,
COLLECTIONS.DEBTS,
debt.$id,
{ metadata: updatedMetadata }
);
// Save variables to database
await saveVariablesToDatabase(debt.id, variables);
@@ -397,17 +393,16 @@ export function DebtCard({ debt, onUpdate, debts, setDebts }: DebtCardProps) {
const checkServerToken = async () => {
try {
const {
data: { user },
} = await supabase.auth.getUser();
const user = await account.get();
if (!user) return;
const { data: profile } = await supabase
.from("user_profiles")
.select("postmark_server_token")
.eq("user_id", user.id)
.single();
const response = await databases.listDocuments(
DATABASE_ID,
COLLECTIONS.USER_PROFILES,
[Query.equal('user_id', user.$id)]
);
const profile = response.documents[0];
setUserProfile(profile);
setHasServerToken(!!profile?.postmark_server_token);
} catch (error) {
@@ -427,20 +422,20 @@ export function DebtCard({ debt, onUpdate, debts, setDebts }: DebtCardProps) {
setIsApproving(true);
try {
const {
data: { user },
} = await supabase.auth.getUser();
const user = await account.get();
if (!user) throw new Error("User not authenticated");
if (sendEmail) {
// Call the send-email function
const { data, error } = await supabase.functions.invoke("send-email", {
body: {
const response = await functions.createExecution(
'send-email', // Function ID
JSON.stringify({
debtId: debt.id,
},
});
})
);
if (error) throw error;
const data = JSON.parse(response.response);
if (response.status === 'failed') throw new Error(data.error || 'Function execution failed');
if (data.requiresConfiguration) {
toast.error("Configuration Required", {
@@ -455,17 +450,16 @@ export function DebtCard({ debt, onUpdate, debts, setDebts }: DebtCardProps) {
});
} else {
// Call the approve-debt function to handle approval without sending email
const { data, error } = await supabase.functions.invoke(
"approve-debt",
{
body: {
debtId: debt.id,
approvalNote: "Approved by user without sending email",
},
}
const response = await functions.createExecution(
'approve-debt', // Function ID
JSON.stringify({
debtId: debt.id,
approvalNote: "Approved by user without sending email",
})
);
if (error) throw error;
const data = JSON.parse(response.response);
if (response.status === 'failed') throw new Error(data.error || 'Function execution failed');
toast.success("Debt Approved", {
description: `Negotiation for ${data.vendor} has been approved and saved.`,
@@ -490,9 +484,11 @@ export function DebtCard({ debt, onUpdate, debts, setDebts }: DebtCardProps) {
const handleReject = async () => {
setIsRejecting(true);
try {
const { error } = await supabase
.from("debts")
.update({
await databases.updateDocument(
DATABASE_ID,
COLLECTIONS.DEBTS,
debt.id,
{
status: "opted_out",
metadata: {
...debt.metadata,
@@ -501,20 +497,25 @@ export function DebtCard({ debt, onUpdate, debts, setDebts }: DebtCardProps) {
reason: "User rejected negotiation",
},
},
})
.eq("id", debt.id);
if (error) throw error;
updated_at: new Date().toISOString(),
}
);
// Log the action
await supabase.from("audit_logs").insert({
debt_id: debt.id,
action: "negotiation_rejected",
details: {
rejectedAt: new Date().toISOString(),
reason: "User rejected negotiation",
},
});
await databases.createDocument(
DATABASE_ID,
COLLECTIONS.AUDIT_LOGS,
ID.unique(),
{
debt_id: debt.id,
action: "negotiation_rejected",
details: {
rejectedAt: new Date().toISOString(),
reason: "User rejected negotiation",
},
created_at: new Date().toISOString(),
}
);
toast.success("Negotiation Rejected", {
description: "The negotiation has been marked as rejected.",
@@ -618,9 +619,10 @@ export function DebtCard({ debt, onUpdate, debts, setDebts }: DebtCardProps) {
</div>
{/* Manual Response Dialog - show when requires manual review */}
{debt.status === "requires_manual_review" && (
{/* TODO: Migrate ManualResponseDialog to Appwrite */}
{/* {debt.status === "requires_manual_review" && (
<ManualResponseDialog debt={debt} onResponseSent={onUpdate} />
)}
)} */}
{/* Approve/Reject Buttons */}
{showApproveRejectButtons() && (
@@ -731,12 +733,13 @@ export function DebtCard({ debt, onUpdate, debts, setDebts }: DebtCardProps) {
</div>
)}
<ConversationTimeline
{/* TODO: Migrate ConversationTimeline to Appwrite */}
{/* <ConversationTimeline
debt={debt}
onDebtUpdate={(debt) => {
setDebts(debts.map((d) => (d.id === debt.id ? debt : d)));
}}
/>
/> */}
</CardContent>
</Card>
);

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import { supabase } from "../lib/supabase";
import type { User } from "@supabase/supabase-js";
import { account } from "../lib/appwrite";
import type { Models } from "appwrite";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -14,25 +14,25 @@ import { BarChart3, LogOut, User as UserIcon, Settings } from "lucide-react";
import { ModeToggle } from "./ModeToggle";
export function Navbar() {
const [user, setUser] = useState<User | null>(null);
const [user, setUser] = useState<Models.User<Models.Preferences> | null>(null);
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
setUser(session?.user ?? null);
account.get().then((currentUser) => {
setUser(currentUser);
}).catch(() => {
setUser(null);
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((event, session) => {
setUser(session?.user ?? null);
});
return () => subscription.unsubscribe();
}, []);
const handleSignOut = async () => {
await supabase.auth.signOut();
window.location.href = "/";
try {
await account.deleteSession('current');
window.location.href = "/";
} catch (error) {
console.error('Sign out error:', error);
// Force redirect even if sign out fails
window.location.href = "/";
}
};
const getInitials = (email: string) => {

173
src/lib/appwrite-admin.ts Normal file
View File

@@ -0,0 +1,173 @@
import { Client, Account, Databases, Functions } from "appwrite";
import { DATABASE_ID, COLLECTIONS } from "./appwrite";
// Export constants for server-side operations
export { DATABASE_ID, COLLECTIONS };
// Create admin client instances for server-side use
const adminClient = createAppwriteAdmin();
export const databases = adminClient.databases;
export const functions = adminClient.functions;
/**
* Creates an Appwrite client with admin privileges for server-side operations
* This client should only be used in trusted contexts like webhooks, API routes, and server-side functions
*/
export function createAppwriteAdmin() {
const appwriteEndpoint = process.env.PUBLIC_APPWRITE_ENDPOINT || import.meta.env.PUBLIC_APPWRITE_ENDPOINT;
const appwriteProjectId = process.env.PUBLIC_APPWRITE_PROJECT_ID || import.meta.env.PUBLIC_APPWRITE_PROJECT_ID;
const appwriteApiKey = process.env.APPWRITE_API_KEY || import.meta.env.APPWRITE_API_KEY;
if (!appwriteEndpoint || !appwriteProjectId || !appwriteApiKey) {
throw new Error("Missing Appwrite configuration for admin operations");
}
const client = new Client()
.setEndpoint(appwriteEndpoint)
.setProject(appwriteProjectId)
.setKey(appwriteApiKey);
return {
client,
account: new Account(client),
databases: new Databases(client),
functions: new Functions(client)
};
}
/**
* Handle database errors with more user-friendly messages
*/
export function handleDatabaseError(error: any) {
let errorMessage = error.message;
if (error.message.includes("permission")) {
errorMessage = "Database access denied - please check permissions";
} else if (error.message.includes("duplicate")) {
errorMessage = "Duplicate entry detected";
} else if (error.message.includes("not found")) {
errorMessage = "Resource not found";
} else if (error.message.includes("required")) {
errorMessage = "Required field is missing";
}
return {
message: errorMessage,
originalError: process.env.NODE_ENV === "development" ? error : undefined,
};
}
/**
* Find user ID by email address in Appwrite
* Searches through users collection by email
*/
export async function getUserIdByEmail(
email: string,
adminClient?: ReturnType<typeof createAppwriteAdmin>
): Promise<string | null> {
const client = adminClient || createAppwriteAdmin();
try {
// Query users by email - assuming users collection exists
const response = await client.databases.listDocuments(
DATABASE_ID,
'users', // This would be the users collection ID in Appwrite
[
// Appwrite uses Query objects for filtering
// Note: This will need to be adjusted based on actual Appwrite schema
]
);
// Filter results by email since Appwrite queries might be different
const user = response.documents.find(user =>
user.email.toLowerCase() === email.toLowerCase()
);
if (user) {
return user.$id;
}
// If not found in main users, check additional emails if that collection exists
try {
const additionalEmailsResponse = await client.databases.listDocuments(
DATABASE_ID,
COLLECTIONS.ADDITIONAL_EMAILS,
[]
);
const additionalEmail = additionalEmailsResponse.documents.find(email_doc =>
email_doc.email_address.toLowerCase() === email.toLowerCase() &&
email_doc.verified === true
);
return additionalEmail?.user_id || null;
} catch (additionalError) {
console.error("Error finding user by additional email:", additionalError);
return null;
}
} catch (error) {
console.error("Error in getUserIdByEmail:", error);
return null;
}
}
/**
* Get full user information by email address
*/
export async function getUserByEmail(
email: string,
adminClient?: ReturnType<typeof createAppwriteAdmin>
) {
const client = adminClient || createAppwriteAdmin();
try {
// Query users by email
const response = await client.databases.listDocuments(
DATABASE_ID,
'users',
[]
);
const user = response.documents.find(user =>
user.email.toLowerCase() === email.toLowerCase()
);
if (user) {
return user;
}
// Check additional emails with user join
try {
const additionalEmailsResponse = await client.databases.listDocuments(
DATABASE_ID,
COLLECTIONS.ADDITIONAL_EMAILS,
[]
);
const additionalEmail = additionalEmailsResponse.documents.find(email_doc =>
email_doc.email_address.toLowerCase() === email.toLowerCase() &&
email_doc.verified === true
);
if (additionalEmail) {
// Get the user record by user_id
const userResponse = await client.databases.getDocument(
DATABASE_ID,
'users',
additionalEmail.user_id
);
return userResponse;
}
return null;
} catch (additionalError) {
console.error("Error finding user by additional email:", additionalError);
return null;
}
} catch (error) {
console.error("Error in getUserByEmail:", error);
return null;
}
}

143
src/lib/appwrite.ts Normal file
View File

@@ -0,0 +1,143 @@
import { Client, Account, Databases, Functions } from "appwrite";
const appwriteEndpoint = import.meta.env.PUBLIC_APPWRITE_ENDPOINT;
const appwriteProjectId = import.meta.env.PUBLIC_APPWRITE_PROJECT_ID;
if (!appwriteEndpoint || !appwriteProjectId) {
throw new Error("Missing Appwrite environment variables");
}
export const client = new Client()
.setEndpoint(appwriteEndpoint)
.setProject(appwriteProjectId);
export const account = new Account(client);
export const databases = new Databases(client);
export const functions = new Functions(client);
// Database and collection IDs (to be configured in Appwrite)
export const DATABASE_ID = import.meta.env.PUBLIC_APPWRITE_DATABASE_ID || "inbox-negotiator-db";
export const COLLECTIONS = {
DEBTS: "debts",
AUDIT_LOGS: "audit_logs",
USER_PROFILES: "user_profiles",
ADDITIONAL_EMAILS: "additional_emails",
EMAIL_PROCESSING_USAGE: "email_processing_usage",
DEBT_VARIABLES: "debt_variables",
CONVERSATION_MESSAGES: "conversation_messages"
};
export type User = {
id: string;
email: string;
created_at: string;
};
export type Debt = {
$id: string;
id: string;
created_at: string;
updated_at: string;
vendor: string;
amount: number;
raw_email: string | null;
status:
| "received"
| "negotiating"
| "approved"
| "sent"
| "awaiting_response"
| "counter_negotiating"
| "requires_manual_review"
| "accepted"
| "rejected"
| "settled"
| "failed"
| "opted_out";
negotiated_plan: string | null;
projected_savings: number;
user_id: string;
description?: string | null;
due_date?: string | null;
conversation_count?: number;
last_message_at?: string;
negotiation_round?: number;
prospected_savings?: number;
actual_savings?: number;
metadata?: Record<string, any> | null;
};
export type AuditLog = {
$id: string;
id: string;
created_at: string;
debt_id: string;
action: string;
details: Record<string, any>;
};
export type UserProfile = {
$id: string;
id: string;
user_id: string;
created_at: string;
updated_at: string;
onboarding_completed: boolean;
first_login_at: string | null;
email_processing_limit: number;
postmark_server_token: string | null;
};
export type AdditionalEmail = {
$id: string;
id: string;
user_id: string;
email_address: string;
verified: boolean;
verification_token: string | null;
created_at: string;
updated_at: string;
};
export type EmailProcessingUsage = {
$id: string;
id: string;
user_id: string;
month_year: string;
emails_processed: number;
created_at: string;
updated_at: string;
};
export type DebtVariable = {
$id: string;
id: string;
debt_id: string;
variable_name: string;
variable_value: string | null;
created_at: string;
updated_at: string;
};
export type ConversationMessage = {
$id: string;
id: string;
debt_id: string;
message_type:
| "initial_debt"
| "negotiation_sent"
| "response_received"
| "counter_offer"
| "acceptance"
| "rejection"
| "manual_response";
direction: "inbound" | "outbound";
subject?: string;
body: string;
from_email?: string;
to_email?: string;
message_id?: string;
ai_analysis?: Record<string, any>;
created_at: string;
updated_at: string;
};

View File

@@ -8,7 +8,8 @@
* - Processing complete email templates
*/
import { supabase } from "./supabase";
import { databases, DATABASE_ID, COLLECTIONS } from "./appwrite-admin";
import { ID } from "appwrite";
export interface VariableProcessingResult {
processedSubject: string;
@@ -68,18 +69,14 @@ export async function loadVariablesFromDatabase(
debtId: string
): Promise<Record<string, string>> {
try {
const { data: dbVariables, error } = await supabase
.from("debt_variables")
.select("variable_name, variable_value")
.eq("debt_id", debtId);
if (error) {
console.error("Error loading variables from database:", error);
throw error;
}
const response = await databases.listDocuments(
DATABASE_ID,
COLLECTIONS.DEBT_VARIABLES,
[`debt_id="${debtId}"`]
);
const loadedVariables: Record<string, string> = {};
dbVariables?.forEach((dbVar) => {
response.documents.forEach((dbVar: any) => {
loadedVariables[dbVar.variable_name] = dbVar.variable_value || "";
});
@@ -100,33 +97,34 @@ export async function saveVariablesToDatabase(
variables: Record<string, string>
): Promise<void> {
try {
// First, delete existing variables for this debt
const { error: deleteError } = await supabase
.from("debt_variables")
.delete()
.eq("debt_id", debtId);
// First, get existing variables for this debt
const existingVariables = await databases.listDocuments(
DATABASE_ID,
COLLECTIONS.DEBT_VARIABLES,
[`debt_id="${debtId}"`]
);
if (deleteError) {
console.error("Error deleting existing variables:", deleteError);
throw deleteError;
// Delete existing variables
for (const variable of existingVariables.documents) {
await databases.deleteDocument(
DATABASE_ID,
COLLECTIONS.DEBT_VARIABLES,
variable.$id
);
}
// Then insert new variables
const variableRecords = Object.entries(variables).map(([name, value]) => ({
debt_id: debtId,
variable_name: name,
variable_value: value,
}));
if (variableRecords.length > 0) {
const { error: insertError } = await supabase
.from("debt_variables")
.insert(variableRecords);
if (insertError) {
console.error("Error inserting variables:", insertError);
throw insertError;
}
// Insert new variables
for (const [name, value] of Object.entries(variables)) {
await databases.createDocument(
DATABASE_ID,
COLLECTIONS.DEBT_VARIABLES,
ID.unique(),
{
debt_id: debtId,
variable_name: name,
variable_value: value,
}
);
}
} catch (error) {
console.error("Error in saveVariablesToDatabase:", error);

View File

@@ -1,153 +1,23 @@
import { createClient } from "@supabase/supabase-js";
import type { SupabaseClient } from "@supabase/supabase-js";
// This file has been migrated to appwrite-admin.ts
// The original Supabase admin functionality is now handled by Appwrite
/**
* Creates a Supabase client with service role key for server-side operations
* This client bypasses Row Level Security (RLS) and should only be used in trusted contexts
* like webhooks, API routes, and server-side functions
* @deprecated Use appwrite-admin.ts instead
* This file contained Supabase admin client functionality that has been migrated to Appwrite
*/
export function createSupabaseAdmin() {
const supabaseUrl =
process.env.PUBLIC_SUPABASE_URL || import.meta.env.PUBLIC_SUPABASE_URL;
const supabaseServiceKey =
process.env.SUPABASE_SERVICE_ROLE_KEY ||
import.meta.env.SUPABASE_SERVICE_ROLE_KEY;
console.log({ supabaseUrl, supabaseServiceKey });
if (!supabaseUrl || !supabaseServiceKey) {
throw new Error(
"Missing Supabase URL or Service Role Key for admin operations"
);
}
return createClient(supabaseUrl, supabaseServiceKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
throw new Error('This function has been migrated to Appwrite. Use appwrite-admin.ts instead.');
}
/**
* Handle database errors with more user-friendly messages
*/
export function handleDatabaseError(error: any) {
let errorMessage = error.message;
if (error.message.includes("row-level security")) {
errorMessage = "Database access denied - please check RLS policies";
} else if (error.message.includes("duplicate key")) {
errorMessage = "Duplicate entry detected";
} else if (error.message.includes("foreign key")) {
errorMessage = "Invalid reference in data";
} else if (error.message.includes("not null")) {
errorMessage = "Required field is missing";
}
return {
message: errorMessage,
originalError: process.env.NODE_ENV === "development" ? error : undefined,
};
throw new Error('This function has been migrated to Appwrite. Use appwrite-admin.ts instead.');
}
/**
* Find user ID by email address (primary or additional email)
* First checks public.users table, then additional_emails if needed
*/
export async function getUserIdByEmail(
email: string,
supabaseAdmin?: SupabaseClient
): Promise<string | null> {
const client = supabaseAdmin || createSupabaseAdmin();
try {
// First try to find user by primary email in public.users table
const { data: primaryUser, error: primaryError } = await client
.from("users")
.select("id")
.eq("email", email.toLowerCase())
.maybeSingle();
if (primaryError) {
console.error("Error finding user by primary email:", primaryError);
}
if (primaryUser) {
return primaryUser.id;
}
// If not found, check additional emails
const { data: additionalEmail, error: additionalError } = await client
.from("additional_emails")
.select("user_id")
.eq("email_address", email.toLowerCase())
// TODO: START REQUIRING VERIFIED ADDITIONAL EMAILS
// .eq("verified", true)
.eq("verified", false)
.maybeSingle();
if (additionalError) {
console.error("Error finding user by additional email:", additionalError);
return null;
}
return additionalEmail?.user_id || null;
} catch (error) {
console.error("Error in getUserIdByEmail:", error);
return null;
}
export async function getUserIdByEmail(email: string): Promise<string | null> {
throw new Error('This function has been migrated to Appwrite. Use appwrite-admin.ts instead.');
}
/**
* Get full user information by email address (primary or additional email)
* First checks public.users table, then additional_emails if needed
*/
export async function getUserByEmail(
email: string,
supabaseAdmin?: SupabaseClient
) {
const client = supabaseAdmin || createSupabaseAdmin();
try {
// First try to find user by primary email in public.users table
const { data: primaryUser, error: primaryError } = await client
.from("users")
.select("*")
.eq("email", email.toLowerCase())
.maybeSingle();
if (primaryError) {
console.error("Error finding user by primary email:", primaryError);
}
if (primaryUser) {
return primaryUser;
}
// If not found, check additional emails and join with users table
const { data: userViaAdditionalEmail, error: additionalError } = await client
.from("additional_emails")
.select(`
user_id,
users!inner (
id,
email,
created_at
)
`)
.eq("email_address", email.toLowerCase())
.eq("verified", true)
.maybeSingle();
if (additionalError) {
console.error("Error finding user by additional email:", additionalError);
return null;
}
return userViaAdditionalEmail?.users || null;
} catch (error) {
console.error("Error in getUserByEmail:", error);
return null;
}
export async function getUserByEmail(email: string) {
throw new Error('This function has been migrated to Appwrite. Use appwrite-admin.ts instead.');
}

View File

@@ -1,13 +1,5 @@
import { createClient } from "@supabase/supabase-js";
const supabaseUrl = import.meta.env.PUBLIC_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error("Missing Supabase environment variables");
}
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
// Type definitions for Appwrite migration
// This file no longer contains Supabase client - use appwrite.ts instead
export type User = {
id: string;

View File

@@ -1,13 +1,14 @@
import type { APIRoute } from "astro";
import {
createSupabaseAdmin,
createAppwriteAdmin,
getUserIdByEmail,
handleDatabaseError,
} from "../../lib/supabase-admin";
} from "../../lib/appwrite-admin";
import { generateObject } from "ai";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { z } from "zod";
import type { SupabaseClient } from "@supabase/supabase-js";
import { DATABASE_ID, COLLECTIONS } from "../../lib/appwrite";
import { ID, Query } from "appwrite";
// Schema for debt information extraction
const debtSchema = z.object({
@@ -125,19 +126,52 @@ async function parseDebtWithAI(emailText: string, fromEmail: string) {
// Function to increment email processing usage
async function incrementEmailUsage(
userId: string,
supabaseAdmin: SupabaseClient,
appwriteAdmin: ReturnType<typeof createAppwriteAdmin>,
) {
try {
// Call the database function to increment usage
const { error } = await supabaseAdmin.rpc("increment_email_usage", {
target_user_id: userId,
});
if (error) {
console.error("Error incrementing email usage:", error);
// In Appwrite, we'll need to implement this differently since there are no stored procedures
// For now, we'll implement a simple increment by finding the current month's usage and updating it
const currentDate = new Date();
const monthYear = `${currentDate.getFullYear()}-${(currentDate.getMonth() + 1).toString().padStart(2, '0')}`;
// Get current usage for this month
const response = await appwriteAdmin.databases.listDocuments(
DATABASE_ID,
COLLECTIONS.EMAIL_PROCESSING_USAGE,
[Query.equal('user_id', userId), Query.equal('month_year', monthYear)]
);
const existingUsage = response.documents[0];
if (existingUsage) {
// Update existing usage
await appwriteAdmin.databases.updateDocument(
DATABASE_ID,
COLLECTIONS.EMAIL_PROCESSING_USAGE,
existingUsage.$id,
{
emails_processed: existingUsage.emails_processed + 1,
updated_at: new Date().toISOString()
}
);
} else {
// Create new usage record
await appwriteAdmin.databases.createDocument(
DATABASE_ID,
COLLECTIONS.EMAIL_PROCESSING_USAGE,
ID.unique(),
{
user_id: userId,
month_year: monthYear,
emails_processed: 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
);
}
} catch (error) {
console.error("Error calling increment_email_usage:", error);
console.error("Error incrementing email usage:", error);
}
}
@@ -145,25 +179,25 @@ async function incrementEmailUsage(
async function checkForExistingNegotiation(
fromEmail: string,
toEmail: string,
supabaseAdmin: any,
appwriteAdmin: ReturnType<typeof createAppwriteAdmin>,
) {
try {
// Look for debts where we've sent emails to this fromEmail and are awaiting response
// Include multiple statuses that indicate we're in an active negotiation
const { data: debts, error } = await supabaseAdmin
.from("debts")
.select("*")
.in("status", ["sent", "awaiting_response", "counter_negotiating"])
.contains("metadata", { fromEmail: fromEmail, toEmail: toEmail })
.order("last_message_at", { ascending: false });
const response = await appwriteAdmin.databases.listDocuments(
DATABASE_ID,
COLLECTIONS.DEBTS,
[Query.in('status', ['sent', 'awaiting_response', 'counter_negotiating']), Query.orderDesc('last_message_at')]
);
if (error) {
console.error("Error checking for existing negotiation:", error);
return null;
}
// Find matching debts based on email metadata
const matchingDebts = response.documents.filter(debt => {
const metadata = debt.metadata as any;
return metadata?.fromEmail === fromEmail &&
metadata?.toEmail === toEmail;
});
// Return the most recent debt that matches
return debts && debts.length > 0 ? debts[0] : null;
// Return the most recent debt that matches (already sorted by orderDesc in query)
return matchingDebts.length > 0 ? matchingDebts[0] : null;
} catch (error) {
console.error("Error in checkForExistingNegotiation:", error);
return null;
@@ -174,7 +208,7 @@ async function checkForExistingNegotiation(
async function handleNegotiationResponse(
debt: any,
emailData: any,
supabaseAdmin: any,
appwriteAdmin: ReturnType<typeof createAppwriteAdmin>,
) {
try {
const textBody = emailData.TextBody || emailData.HtmlBody || "";
@@ -183,45 +217,58 @@ async function handleNegotiationResponse(
const messageId = emailData.MessageID || `inbound-${Date.now()}`;
// First, record this message in the conversation
await supabaseAdmin.from("conversation_messages").insert({
debt_id: debt.id,
message_type: "response_received",
direction: "inbound",
subject: subject,
body: textBody,
from_email: fromEmail,
to_email: emailData.ToFull?.[0]?.Email || emailData.To || "",
message_id: messageId,
});
await appwriteAdmin.databases.createDocument(
DATABASE_ID,
COLLECTIONS.CONVERSATION_MESSAGES,
ID.unique(),
{
debt_id: debt.$id,
message_type: "response_received",
direction: "inbound",
subject: subject,
body: textBody,
from_email: fromEmail,
to_email: emailData.ToFull?.[0]?.Email || emailData.To || "",
message_id: messageId,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
);
// Update debt conversation tracking
await supabaseAdmin
.from("debts")
.update({
await appwriteAdmin.databases.updateDocument(
DATABASE_ID,
COLLECTIONS.DEBTS,
debt.$id,
{
conversation_count: debt.conversation_count + 1,
last_message_at: new Date().toISOString(),
status: "counter_negotiating", // Temporary status while analyzing
})
.eq("id", debt.id);
updated_at: new Date().toISOString()
}
);
// Call the analyze-response function
const supabaseUrl = process.env.SUPABASE_URL ||
import.meta.env.PUBLIC_SUPABASE_URL;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY ||
import.meta.env.SUPABASE_SERVICE_ROLE_KEY;
const appwriteEndpoint = process.env.PUBLIC_APPWRITE_ENDPOINT ||
import.meta.env.PUBLIC_APPWRITE_ENDPOINT;
const appwriteProjectId = process.env.PUBLIC_APPWRITE_PROJECT_ID ||
import.meta.env.PUBLIC_APPWRITE_PROJECT_ID;
const appwriteApiKey = process.env.APPWRITE_API_KEY ||
import.meta.env.APPWRITE_API_KEY;
if (supabaseUrl && supabaseServiceKey) {
const analyzeUrl = `${supabaseUrl}/functions/v1/analyze-response`;
if (appwriteEndpoint && appwriteProjectId && appwriteApiKey) {
const analyzeUrl = `${appwriteEndpoint}/functions/v1/analyze-response`;
try {
const response = await fetch(analyzeUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${supabaseServiceKey}`,
"X-Appwrite-Project": appwriteProjectId,
"X-Appwrite-Key": appwriteApiKey,
"Content-Type": "application/json",
},
body: JSON.stringify({
debtId: debt.id,
debtId: debt.$id,
fromEmail,
subject,
body: textBody,
@@ -233,20 +280,6 @@ async function handleNegotiationResponse(
const result = await response.json();
console.log("Response analysis completed:", result);
// Update the conversation message with AI analysis
// !MAYBE NEEDED
// await supabaseAdmin
// .from("conversation_messages")
// .update({
// ai_analysis: result.analysis,
// message_type: result.analysis?.intent === "acceptance"
// ? "acceptance"
// : result.analysis?.intent === "rejection"
// ? "rejection"
// : "response_received",
// })
// .eq("message_id", messageId);
return new Response(
JSON.stringify({
success: true,
@@ -270,22 +303,22 @@ async function handleNegotiationResponse(
}
// Fallback: just log the response and mark for manual review
await supabaseAdmin.from("audit_logs").insert({
debt_id: debt.id,
action: "response_received_fallback",
details: {
fromEmail,
subject,
bodyPreview: textBody.substring(0, 200),
requiresManualReview: true,
},
});
// Update status to require user review
// await supabaseAdmin
// .from("debts")
// .update({ status: "awaiting_response" })
// .eq("id", debt.id);
await appwriteAdmin.databases.createDocument(
DATABASE_ID,
COLLECTIONS.AUDIT_LOGS,
ID.unique(),
{
debt_id: debt.$id,
action: "response_received_fallback",
details: {
fromEmail,
subject,
bodyPreview: textBody.substring(0, 200),
requiresManualReview: true,
},
created_at: new Date().toISOString()
}
);
return new Response(
JSON.stringify({ success: true, message: "Response logged" }),
@@ -308,12 +341,12 @@ async function handleNegotiationResponse(
export const POST: APIRoute = async ({ request }) => {
try {
// Create service role client for webhook operations (bypasses RLS)
let supabaseAdmin;
// Create admin client for webhook operations
let appwriteAdmin;
try {
supabaseAdmin = createSupabaseAdmin();
appwriteAdmin = createAppwriteAdmin();
} catch (configError) {
console.error("Supabase admin configuration error:", configError);
console.error("Appwrite admin configuration error:", configError);
return new Response(
JSON.stringify({ error: "Server configuration error" }),
{
@@ -339,7 +372,7 @@ export const POST: APIRoute = async ({ request }) => {
const toEmail = data.ToFull?.[0]?.Email || data.To || "";
// Find the user who should receive this debt
const userId = await getUserIdByEmail(toEmail, supabaseAdmin);
const userId = await getUserIdByEmail(toEmail, appwriteAdmin);
if (!userId) {
console.warn(`No user found for email: ${toEmail}`);
return new Response("No matching user found", { status: 200 });
@@ -349,19 +382,19 @@ export const POST: APIRoute = async ({ request }) => {
const existingDebt = await checkForExistingNegotiation(
fromEmail,
toEmail,
supabaseAdmin,
appwriteAdmin,
);
console.log({ existingDebt, fromEmail, toEmail });
if (existingDebt) {
console.log(
`Found existing negotiation for debt ${existingDebt.id}, analyzing response...`,
`Found existing negotiation for debt ${existingDebt.$id}, analyzing response...`,
);
return await handleNegotiationResponse(existingDebt, data, supabaseAdmin);
return await handleNegotiationResponse(existingDebt, data, appwriteAdmin);
}
// Increment email processing usage
await incrementEmailUsage(userId, supabaseAdmin);
await incrementEmailUsage(userId, appwriteAdmin);
// Check for opt-out using AI
const optOutDetection = await detectOptOutWithAI(textBody, fromEmail);
@@ -383,15 +416,22 @@ export const POST: APIRoute = async ({ request }) => {
if (hasOptOut) {
// Log opt-out and don't process further
const { error } = await supabaseAdmin.from("debts").insert({
user_id: userId,
vendor: fromEmail,
amount: 0,
raw_email: textBody,
status: "opted_out",
});
if (error) {
try {
await appwriteAdmin.databases.createDocument(
DATABASE_ID,
COLLECTIONS.DEBTS,
ID.unique(),
{
user_id: userId,
vendor: fromEmail,
amount: 0,
raw_email: textBody,
status: "opted_out",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
);
} catch (error) {
console.error("Error logging opt-out:", error);
const errorInfo = handleDatabaseError(error);
return new Response(JSON.stringify({ error: errorInfo.message }), {
@@ -418,44 +458,54 @@ export const POST: APIRoute = async ({ request }) => {
}
// Insert debt record with AI-extracted information
const { data: insertedDebt, error: insertError } = await supabaseAdmin
.from("debts")
.insert({
user_id: userId,
vendor: debtInfo.vendor,
amount: debtInfo.amount,
raw_email: textBody,
status: "received",
description: debtInfo.description,
due_date: debtInfo.dueDate,
conversation_count: 1,
last_message_at: new Date().toISOString(),
negotiation_round: 1,
metadata: {
isDebtCollection: debtInfo.isDebtCollection,
subject: data.Subject,
fromEmail: fromEmail,
toEmail: toEmail,
},
})
.select()
.single();
let insertedDebt;
try {
insertedDebt = await appwriteAdmin.databases.createDocument(
DATABASE_ID,
COLLECTIONS.DEBTS,
ID.unique(),
{
user_id: userId,
vendor: debtInfo.vendor,
amount: debtInfo.amount,
raw_email: textBody,
status: "received",
description: debtInfo.description,
due_date: debtInfo.dueDate,
conversation_count: 1,
last_message_at: new Date().toISOString(),
negotiation_round: 1,
projected_savings: 0,
metadata: {
isDebtCollection: debtInfo.isDebtCollection,
subject: data.Subject,
fromEmail: fromEmail,
toEmail: toEmail,
},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
);
if (!insertError && insertedDebt) {
// Record the initial debt email as the first conversation message
await supabaseAdmin.from("conversation_messages").insert({
debt_id: insertedDebt.id,
message_type: "initial_debt",
direction: "inbound",
subject: data.Subject,
body: textBody,
from_email: fromEmail,
to_email: toEmail,
message_id: data.MessageID || `initial-${Date.now()}`,
});
}
if (insertError) {
await appwriteAdmin.databases.createDocument(
DATABASE_ID,
COLLECTIONS.CONVERSATION_MESSAGES,
ID.unique(),
{
debt_id: insertedDebt.$id,
message_type: "initial_debt",
direction: "inbound",
subject: data.Subject,
body: textBody,
from_email: fromEmail,
to_email: toEmail,
message_id: data.MessageID || `initial-${Date.now()}`,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
);
} catch (insertError) {
console.error("Error inserting debt:", insertError);
const errorInfo = handleDatabaseError(insertError);
@@ -472,33 +522,42 @@ export const POST: APIRoute = async ({ request }) => {
}
// Log the email receipt
await supabaseAdmin.from("audit_logs").insert({
debt_id: insertedDebt.id,
action: "email_received",
details: {
vendor: debtInfo.vendor,
amount: debtInfo.amount,
subject: data.Subject,
aiParsed: true,
},
});
await appwriteAdmin.databases.createDocument(
DATABASE_ID,
COLLECTIONS.AUDIT_LOGS,
ID.unique(),
{
debt_id: insertedDebt.$id,
action: "email_received",
details: {
vendor: debtInfo.vendor,
amount: debtInfo.amount,
subject: data.Subject,
aiParsed: true,
},
created_at: new Date().toISOString(),
}
);
// Trigger negotiation function if this is a legitimate debt
if (debtInfo.amount > 0 && debtInfo.isDebtCollection) {
// Access environment variables through Astro runtime
const supabaseUrl = process.env.SUPABASE_URL ||
import.meta.env.PUBLIC_SUPABASE_URL;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY ||
import.meta.env.SUPABASE_SERVICE_ROLE_KEY;
const appwriteEndpoint = process.env.PUBLIC_APPWRITE_ENDPOINT ||
import.meta.env.PUBLIC_APPWRITE_ENDPOINT;
const appwriteProjectId = process.env.PUBLIC_APPWRITE_PROJECT_ID ||
import.meta.env.PUBLIC_APPWRITE_PROJECT_ID;
const appwriteApiKey = process.env.APPWRITE_API_KEY ||
import.meta.env.APPWRITE_API_KEY;
if (supabaseUrl && supabaseServiceKey) {
const negotiateUrl = `${supabaseUrl}/functions/v1/negotiate`;
if (appwriteEndpoint && appwriteProjectId && appwriteApiKey) {
const negotiateUrl = `${appwriteEndpoint}/functions/v1/negotiate`;
try {
await fetch(negotiateUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${supabaseServiceKey}`,
"X-Appwrite-Project": appwriteProjectId,
"X-Appwrite-Key": appwriteApiKey,
"Content-Type": "application/json",
},
body: JSON.stringify({ record: insertedDebt }),
@@ -509,7 +568,7 @@ export const POST: APIRoute = async ({ request }) => {
}
} else {
console.warn(
"Supabase environment variables not configured for negotiation trigger",
"Appwrite environment variables not configured for negotiation trigger",
);
}
}