Add Juno Engine templates and Bedrock email flow
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2011054878
commit
71a75892a5
256
.github/workflows/deploy-ec2.yml
vendored
256
.github/workflows/deploy-ec2.yml
vendored
@ -1,130 +1,162 @@
|
|||||||
name: Deploy to EC2
|
name: build-deploy-junohub-ec2
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE_NAME: junohub-app
|
||||||
|
CONTAINER_NAME: junohub-app
|
||||||
|
# Directory on the EC2 instance where app + compose live
|
||||||
|
REMOTE_DIR: /junohub
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
paths-ignore:
|
||||||
permissions:
|
- '*.md'
|
||||||
contents: read
|
- '.gitignore'
|
||||||
|
- 'docs/**'
|
||||||
|
- 'README.md'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-app:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
env:
|
|
||||||
NODE_ENV: production
|
|
||||||
EC2_ENV: ${{ secrets.EC2_ENV }}
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Use Node.js
|
- name: Set up Docker Buildx
|
||||||
uses: actions/setup-node@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build application image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
context: .
|
||||||
cache: 'npm'
|
file: Dockerfile
|
||||||
|
push: false
|
||||||
|
load: true
|
||||||
|
tags: ${{ env.IMAGE_NAME }}:latest
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
- name: Deploy to EC2 with rollback
|
- name: Save application image
|
||||||
uses: appleboy/ssh-action@v1.2.0
|
run: |
|
||||||
env:
|
docker save -o junohub-app_latest.tar ${{ env.IMAGE_NAME }}:latest
|
||||||
EC2_ENV: ${{ secrets.EC2_ENV }}
|
echo "✅ Saved application image"
|
||||||
|
|
||||||
|
- name: Upload application image
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
host: ${{ secrets.EC2_HOST }}
|
name: app-image
|
||||||
username: ${{ secrets.EC2_USER }}
|
path: junohub-app_latest.tar
|
||||||
key: ${{ secrets.EC2_SSH_KEY }}
|
|
||||||
port: ${{ secrets.EC2_SSH_PORT || '22' }}
|
|
||||||
script: |
|
|
||||||
set -e
|
|
||||||
|
|
||||||
APP_DIR="${APP_DIR:-/home/ubuntu/JUNOHUB}"
|
deploy:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
needs: [build-app]
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
echo "Using app directory: $APP_DIR"
|
- name: Download application image
|
||||||
# Create directory with sudo if needed, then ensure ownership
|
uses: actions/download-artifact@v4
|
||||||
sudo mkdir -p "$APP_DIR" || mkdir -p "$APP_DIR"
|
with:
|
||||||
sudo chown -R $USER:$USER "$APP_DIR" || true
|
name: app-image
|
||||||
cd "$APP_DIR"
|
path: docker
|
||||||
|
|
||||||
# Initialize git repo if needed
|
- name: Verify image tar
|
||||||
if [ ! -d ".git" ]; then
|
run: |
|
||||||
echo "Cloning repository for the first time..."
|
if [ ! -f docker/junohub-app_latest.tar ]; then
|
||||||
git clone ${{ github.repositoryUrl }} .
|
echo "ERROR: Application image tar missing after download"
|
||||||
fi
|
ls -la docker/ || true
|
||||||
|
|
||||||
git remote set-url origin ${{ github.repositoryUrl }}
|
|
||||||
|
|
||||||
PREV_COMMIT=$(git rev-parse HEAD || echo "")
|
|
||||||
echo "Previous commit: $PREV_COMMIT"
|
|
||||||
|
|
||||||
echo "Fetching latest changes..."
|
|
||||||
git fetch origin
|
|
||||||
git checkout ${{ github.ref_name }}
|
|
||||||
git reset --hard ${{ github.sha }}
|
|
||||||
|
|
||||||
echo "Writing .env file from EC2_ENV secret..."
|
|
||||||
# Write .env file AFTER pulling latest code so it's in the right location
|
|
||||||
# EC2_ENV is provided by GitHub as a multi-line secret matching your local .env
|
|
||||||
printf '%s\n' "$EC2_ENV" > .env
|
|
||||||
|
|
||||||
echo "Checking Docker and Docker Compose..."
|
|
||||||
if ! command -v docker >/dev/null 2>&1; then
|
|
||||||
echo "Error: Docker is not installed"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command -v docker-compose >/dev/null 2>&1; then
|
|
||||||
DC="sudo docker-compose"
|
|
||||||
elif sudo docker compose version >/dev/null 2>&1; then
|
|
||||||
DC="sudo docker compose"
|
|
||||||
else
|
|
||||||
echo "Error: Docker Compose is not installed"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Using: $DC"
|
|
||||||
$DC version
|
|
||||||
|
|
||||||
echo "Stopping existing containers..."
|
|
||||||
$DC down || true
|
|
||||||
|
|
||||||
echo "Building and starting containers with docker-compose..."
|
|
||||||
set +e
|
|
||||||
$DC up -d --build
|
|
||||||
DEPLOY_EXIT=$?
|
|
||||||
set -e
|
|
||||||
|
|
||||||
if [ $DEPLOY_EXIT -ne 0 ]; then
|
|
||||||
echo "Deployment failed, attempting rollback..."
|
|
||||||
if [ -n "$PREV_COMMIT" ]; then
|
|
||||||
git reset --hard "$PREV_COMMIT"
|
|
||||||
printf '%s\n' "$EC2_ENV" > .env
|
|
||||||
$DC up -d --build || true
|
|
||||||
fi
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Waiting for containers to be healthy..."
|
|
||||||
sleep 10
|
|
||||||
|
|
||||||
echo "Running health check..."
|
|
||||||
HEALTH_URL="${HEALTH_URL:-http://localhost:3000/api/health}"
|
|
||||||
|
|
||||||
for i in {1..15}; do
|
|
||||||
if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then
|
|
||||||
echo "Health check passed on attempt $i."
|
|
||||||
echo "Deployment successful!"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Health check attempt $i failed, retrying in 5 seconds..."
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Health check failed after multiple attempts, rolling back..."
|
|
||||||
if [ -n "$PREV_COMMIT" ]; then
|
|
||||||
git reset --hard "$PREV_COMMIT"
|
|
||||||
printf '%s\n' "$EC2_ENV" > .env
|
|
||||||
$DC up -d --build || true
|
|
||||||
fi
|
|
||||||
exit 1
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ Application image tar ready for deploy"
|
||||||
|
|
||||||
|
- name: Set up environment file
|
||||||
|
run: |
|
||||||
|
# Base env for production container
|
||||||
|
echo "IMAGE_NAME=${{ env.IMAGE_NAME }}" > .env
|
||||||
|
echo "CONTAINER_NAME=${{ env.CONTAINER_NAME }}" >> .env
|
||||||
|
echo "NODE_ENV=production" >> .env
|
||||||
|
|
||||||
|
# Append project-specific environment from EC2_ENV secret
|
||||||
|
# EC2_ENV should contain additional KEY=VALUE lines
|
||||||
|
echo "${{ secrets.EC2_ENV }}" >> .env
|
||||||
|
|
||||||
|
echo "Environment file created (sensitive values masked):"
|
||||||
|
cat .env | sed 's/\(PASS\|SECRET\|KEY\|TOKEN\|DATABASE_URL\)=.*/\1=***MASKED***/g' || true
|
||||||
|
|
||||||
|
- name: Setup SSH
|
||||||
|
run: |
|
||||||
|
# Set up the SSH key from GitHub Secrets
|
||||||
|
echo "${{ secrets.EC2_SSH_KEY }}" | tr -d '\r' > ssh_key.pem
|
||||||
|
chmod 400 ssh_key.pem
|
||||||
|
|
||||||
|
- name: Setup directories on server
|
||||||
|
run: |
|
||||||
|
ssh -i ssh_key.pem -o StrictHostKeyChecking=no ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }} << 'ENDSSH'
|
||||||
|
# Create directory for docker-compose file, .env and image tar
|
||||||
|
mkdir -p ${{ env.REMOTE_DIR }}
|
||||||
|
echo "✅ Remote directory ensured at ${{ env.REMOTE_DIR }}"
|
||||||
|
ENDSSH
|
||||||
|
|
||||||
|
- name: Copy deployment files to server
|
||||||
|
run: |
|
||||||
|
# Copy docker-compose (app + Postgres), and .env
|
||||||
|
scp -i ssh_key.pem -o StrictHostKeyChecking=no docker-compose.yml ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }}:${{ env.REMOTE_DIR }}/
|
||||||
|
scp -i ssh_key.pem -o StrictHostKeyChecking=no .env ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }}:${{ env.REMOTE_DIR }}/
|
||||||
|
|
||||||
|
# Copy application image tar
|
||||||
|
echo "Copying Docker image to server..."
|
||||||
|
scp -i ssh_key.pem -o StrictHostKeyChecking=no docker/junohub-app_latest.tar ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }}:${{ env.REMOTE_DIR }}/
|
||||||
|
echo "✅ Successfully copied Docker image to server"
|
||||||
|
|
||||||
|
- name: Deploy with rolling update
|
||||||
|
run: |
|
||||||
|
ssh -i ssh_key.pem -o StrictHostKeyChecking=no ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }} << 'ENDSSH'
|
||||||
|
set -e
|
||||||
|
cd ${{ env.REMOTE_DIR }}
|
||||||
|
|
||||||
|
# Basic disk space check (do not touch volumes)
|
||||||
|
echo "=== DISK SPACE CHECK ==="
|
||||||
|
df -h
|
||||||
|
AVAILABLE_SPACE=\$(df -BG / | tail -1 | awk '{print \$4}' | sed 's/G//')
|
||||||
|
if [ "\$AVAILABLE_SPACE" -lt 5 ]; then
|
||||||
|
echo "⚠️ Low disk space: \${AVAILABLE_SPACE}GB available. Cleaning up dangling images..."
|
||||||
|
docker system prune -af --volumes=false || true
|
||||||
|
docker image prune -af || true
|
||||||
|
df -h
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify Docker image tar exists
|
||||||
|
if [ ! -f junohub-app_latest.tar ]; then
|
||||||
|
echo "ERROR: No application image tar found. Cannot deploy without pre-built image."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== LOADING APPLICATION DOCKER IMAGE ==="
|
||||||
|
docker load -i junohub-app_latest.tar
|
||||||
|
rm -f junohub-app_latest.tar
|
||||||
|
echo "✅ Application image loaded"
|
||||||
|
|
||||||
|
# Rolling update with docker compose (app + Postgres)
|
||||||
|
echo "=== ROLLING UPDATE (docker compose up -d) ==="
|
||||||
|
if ! docker compose -f docker-compose.yml --env-file .env up -d --remove-orphans; then
|
||||||
|
echo "ERROR: Deployment failed"
|
||||||
|
docker compose -f docker-compose.yml down || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Deployment successful! Current services:"
|
||||||
|
docker compose -f docker-compose.yml --env-file .env ps
|
||||||
|
|
||||||
|
# Final cleanup (preserve volumes; prune dangling images only)
|
||||||
|
docker system prune -f --volumes=false || true
|
||||||
|
ENDSSH
|
||||||
|
|
||||||
|
- name: Local cleanup
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
rm -f ssh_key.pem
|
||||||
|
rm -f .env
|
||||||
|
rm -f docker/*.tar || true
|
||||||
|
docker system prune -f || true
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -32,7 +32,7 @@ yarn-error.log*
|
|||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@ -15,10 +15,12 @@ COPY . .
|
|||||||
# Build the Next.js application
|
# Build the Next.js application
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Remove dev dependencies to reduce image size
|
# Copy and set up entrypoint script
|
||||||
RUN npm prune --production
|
COPY docker-entrypoint.sh /usr/local/bin/
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Use production start command
|
# Use entrypoint to run migrations before starting the app
|
||||||
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
CMD ["npm", "start"]
|
CMD ["npm", "start"]
|
||||||
|
|||||||
154
Documents/GMAIL_WEBHOOK_SETUP.md
Normal file
154
Documents/GMAIL_WEBHOOK_SETUP.md
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
# Gmail Webhook Setup Guide
|
||||||
|
|
||||||
|
This guide explains how to set up automatic email detection and AI-powered replies using Gmail push notifications.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
When a customer sends an email to your Gmail account, Gmail will send a push notification to our webhook endpoint. The webhook then:
|
||||||
|
1. Fetches the incoming email
|
||||||
|
2. Analyzes it using your store's knowledge base
|
||||||
|
3. Generates an AI-powered reply
|
||||||
|
4. Sends the reply automatically
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **Gmail Integration**: Your Gmail account must be connected via `/dashboard/juno-engine/gmail-connect`
|
||||||
|
2. **Knowledge Base**: Your store must have FAQs or documents in the knowledge base
|
||||||
|
3. **Google Cloud Pub/Sub**: Required for Gmail push notifications
|
||||||
|
|
||||||
|
## Step 1: Set Up Google Cloud Pub/Sub
|
||||||
|
|
||||||
|
Gmail push notifications require Google Cloud Pub/Sub. Follow these steps:
|
||||||
|
|
||||||
|
### 1.1 Create a Pub/Sub Topic
|
||||||
|
|
||||||
|
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||||
|
2. Navigate to **Pub/Sub** > **Topics**
|
||||||
|
3. Click **Create Topic**
|
||||||
|
4. Name it `gmail-notifications` (or any name you prefer)
|
||||||
|
5. Click **Create**
|
||||||
|
|
||||||
|
### 1.2 Create a Subscription
|
||||||
|
|
||||||
|
1. In the topic page, click **Create Subscription**
|
||||||
|
2. Name it `gmail-webhook-subscription`
|
||||||
|
3. Choose **Push** delivery type
|
||||||
|
4. Set the **Endpoint URL** to: `https://yourdomain.com/api/webhooks/gmail`
|
||||||
|
- Replace `yourdomain.com` with your actual domain
|
||||||
|
- For local development with ngrok: `https://your-ngrok-url.ngrok-free.dev/api/webhooks/gmail`
|
||||||
|
5. Click **Create**
|
||||||
|
|
||||||
|
### 1.3 Grant Gmail API Access
|
||||||
|
|
||||||
|
1. Go to **IAM & Admin** > **Service Accounts**
|
||||||
|
2. Find or create a service account for Gmail API
|
||||||
|
3. Grant it the `pubsub.topics.publish` permission on your topic
|
||||||
|
|
||||||
|
### 1.4 Get Topic Name
|
||||||
|
|
||||||
|
Copy the full topic name in the format:
|
||||||
|
```
|
||||||
|
projects/YOUR_PROJECT_ID/topics/gmail-notifications
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Configure Environment Variables
|
||||||
|
|
||||||
|
Add the Pub/Sub topic name to your `.env` file:
|
||||||
|
|
||||||
|
```env
|
||||||
|
GMAIL_PUBSUB_TOPIC=projects/YOUR_PROJECT_ID/topics/gmail-notifications
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Enable Gmail Watch
|
||||||
|
|
||||||
|
Once Pub/Sub is set up, enable Gmail push notifications:
|
||||||
|
|
||||||
|
### Option A: Via API
|
||||||
|
|
||||||
|
Make a POST request to:
|
||||||
|
```
|
||||||
|
POST /api/integrations/gmail/watch
|
||||||
|
```
|
||||||
|
|
||||||
|
This will set up Gmail to send push notifications to your webhook.
|
||||||
|
|
||||||
|
### Option B: Via UI (Recommended)
|
||||||
|
|
||||||
|
Add a button in the Gmail connect page to enable webhooks (coming soon).
|
||||||
|
|
||||||
|
## Step 4: Verify Webhook
|
||||||
|
|
||||||
|
Gmail will send a verification request to your webhook endpoint. The endpoint automatically handles verification by returning the challenge token.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Webhook Flow
|
||||||
|
|
||||||
|
1. **Customer sends email** → Gmail receives it
|
||||||
|
2. **Gmail sends push notification** → Webhook endpoint receives notification
|
||||||
|
3. **Webhook processes email**:
|
||||||
|
- Fetches the email content
|
||||||
|
- Extracts sender, subject, and body
|
||||||
|
- Searches knowledge base for relevant information
|
||||||
|
- Generates AI-powered reply using Bedrock
|
||||||
|
4. **Reply is sent** → Customer receives automated response
|
||||||
|
5. **Email is marked as read** → Prevents duplicate processing
|
||||||
|
|
||||||
|
### Email Processing Logic
|
||||||
|
|
||||||
|
- Only processes emails from external senders (not same domain)
|
||||||
|
- Uses knowledge base to find relevant answers
|
||||||
|
- Generates contextual replies using AI
|
||||||
|
- Maintains email threading (replies are properly threaded)
|
||||||
|
- Logs all sent emails for tracking
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
Check logs for:
|
||||||
|
- Webhook notifications received
|
||||||
|
- Emails processed
|
||||||
|
- Replies sent
|
||||||
|
- Any errors during processing
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Webhook Not Receiving Notifications
|
||||||
|
|
||||||
|
1. Verify Pub/Sub topic exists and is configured correctly
|
||||||
|
2. Check that Gmail watch is active (watch expires after 7 days)
|
||||||
|
3. Verify webhook URL is accessible (not behind firewall)
|
||||||
|
4. Check Google Cloud Console logs for Pub/Sub errors
|
||||||
|
|
||||||
|
### Replies Not Being Sent
|
||||||
|
|
||||||
|
1. Verify Gmail integration is connected
|
||||||
|
2. Check that store has active knowledge base content
|
||||||
|
3. Review error logs for specific issues
|
||||||
|
4. Ensure Bedrock API credentials are configured
|
||||||
|
|
||||||
|
### Watch Expired
|
||||||
|
|
||||||
|
Gmail watch requests expire after 7 days. You need to renew them:
|
||||||
|
- Call `/api/integrations/gmail/watch` again
|
||||||
|
- Or set up a cron job to renew automatically
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Webhook endpoint verifies Gmail notifications
|
||||||
|
- Only processes emails for authenticated users
|
||||||
|
- Validates store ownership before processing
|
||||||
|
- Logs all actions for audit trail
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- Gmail watch expires after 7 days (needs renewal)
|
||||||
|
- Only processes unread emails in inbox
|
||||||
|
- Requires active knowledge base for quality replies
|
||||||
|
- May have rate limits from Gmail API
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- Set up automatic watch renewal (cron job)
|
||||||
|
- Add email filtering rules (only reply to certain senders)
|
||||||
|
- Implement reply templates
|
||||||
|
- Add analytics dashboard for email metrics
|
||||||
121
README.md
121
README.md
@ -21,7 +21,7 @@ The frontend and backend are implemented in a single Next.js App Router project.
|
|||||||
- **Background / Integrations**:
|
- **Background / Integrations**:
|
||||||
- Gmail APIs (via OAuth)
|
- Gmail APIs (via OAuth)
|
||||||
- Shopify APIs
|
- Shopify APIs
|
||||||
- Gemini for embeddings and chat
|
- AWS Bedrock for embeddings and chat (Titan Text Embeddings V2 + Claude 3 Haiku)
|
||||||
- **Deployment**: Docker + GitHub Actions → AWS EC2
|
- **Deployment**: Docker + GitHub Actions → AWS EC2
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -48,9 +48,9 @@ Copy `.env.example` (or the variables listed below) into a local `.env` file and
|
|||||||
- `SMTP_SECURE`
|
- `SMTP_SECURE`
|
||||||
- `SMTP_USER`
|
- `SMTP_USER`
|
||||||
- `SMTP_PASSWORD`
|
- `SMTP_PASSWORD`
|
||||||
- `GEMINI_API_KEY`
|
- `AWS_ACCESS_KEY_ID` (for AWS Bedrock)
|
||||||
- `GEMINI_EMBEDDING_MODEL`
|
- `AWS_SECRET_ACCESS_KEY` (for AWS Bedrock)
|
||||||
- `GEMINI_CHAT_MODEL`
|
- `AWS_REGION` (default: `us-east-1`)
|
||||||
- `ADMIN_EMAIL`
|
- `ADMIN_EMAIL`
|
||||||
- `ADMIN_PASSWORD`
|
- `ADMIN_PASSWORD`
|
||||||
|
|
||||||
@ -119,6 +119,119 @@ This gives you a sharp deployment with an automatic rollback path.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## AWS Bedrock Setup
|
||||||
|
|
||||||
|
The knowledge base feature uses AWS Bedrock for AI-powered embeddings and chat completion.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
1. **AWS Account**: Create an account at https://aws.amazon.com/
|
||||||
|
2. **Enable Bedrock Access**:
|
||||||
|
- Go to https://console.aws.amazon.com/bedrock/
|
||||||
|
- Navigate to "Model access" in the left sidebar
|
||||||
|
- Request access to:
|
||||||
|
- **Amazon Titan Text Embeddings V2** (for embeddings)
|
||||||
|
- **Anthropic Claude 3 Haiku** (for chat completion)
|
||||||
|
- Wait for approval (usually instant for Titan, may take a few hours for Claude)
|
||||||
|
|
||||||
|
### IAM Setup
|
||||||
|
|
||||||
|
Create an IAM user or role with Bedrock permissions:
|
||||||
|
|
||||||
|
1. Go to IAM Console → Users (or Roles)
|
||||||
|
2. Create a new user/role or select existing
|
||||||
|
3. Attach the following policy (or create a custom policy):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["bedrock:InvokeModel"],
|
||||||
|
"Resource": [
|
||||||
|
"arn:aws:bedrock:*::foundation-model/amazon.titan-embed-text-v2:0",
|
||||||
|
"arn:aws:bedrock:*::foundation-model/anthropic.claude-3-haiku-20240307-v1:0"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Create Access Keys** (for IAM user):
|
||||||
|
- Go to Security credentials tab
|
||||||
|
- Click "Create access key"
|
||||||
|
- Choose "Application running outside AWS" or "EC2" depending on your deployment
|
||||||
|
- Copy the Access Key ID and Secret Access Key
|
||||||
|
- Add these to your `.env` file
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Add to your `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AWS_ACCESS_KEY_ID="your-access-key-id"
|
||||||
|
AWS_SECRET_ACCESS_KEY="your-secret-access-key"
|
||||||
|
AWS_REGION="us-east-1" # or your preferred region
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Migration
|
||||||
|
|
||||||
|
After setting up AWS Bedrock, you need to migrate existing embeddings:
|
||||||
|
|
||||||
|
1. **Run the database migration** to change embedding dimensions from 1536 to 1024:
|
||||||
|
```bash
|
||||||
|
npx prisma migrate deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Re-embed existing knowledge base data**:
|
||||||
|
```bash
|
||||||
|
npm run re-embed-knowledge-base
|
||||||
|
```
|
||||||
|
|
||||||
|
This script will:
|
||||||
|
- Read all existing `KnowledgeChunk` records
|
||||||
|
- Generate new 1024-dimension embeddings using Bedrock Titan V2
|
||||||
|
- Update the database with new embeddings
|
||||||
|
- Process in batches to avoid rate limiting
|
||||||
|
|
||||||
|
### Models Used
|
||||||
|
|
||||||
|
- **Embeddings**: Amazon Titan Text Embeddings V2 (`amazon.titan-embed-text-v2:0`)
|
||||||
|
- Output dimension: 1024 (configurable: 256, 512, or 1024)
|
||||||
|
- Used for: FAQ embeddings, document chunk embeddings, query embeddings
|
||||||
|
|
||||||
|
- **Chat**: Anthropic Claude 3 Haiku (`anthropic.claude-3-haiku-20240307-v1:0`)
|
||||||
|
- Used for: RAG (Retrieval-Augmented Generation) query responses
|
||||||
|
- Fast and cost-effective for knowledge base Q&A
|
||||||
|
|
||||||
|
### Optional: Override Models
|
||||||
|
|
||||||
|
You can override the default models via environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BEDROCK_EMBEDDING_MODEL="amazon.titan-embed-text-v2:0"
|
||||||
|
BEDROCK_CHAT_MODEL="anthropic.claude-3-haiku-20240307-v1:0"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prisma:
|
||||||
|
|
||||||
|
If you want to visualize the database:
|
||||||
|
```
|
||||||
|
export DATABASE_URL='<YOUR_DB_URL>'
|
||||||
|
npx prisma studio
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
- **"Access Denied" errors**: Ensure Bedrock model access is enabled in AWS Console
|
||||||
|
- **"Invalid credentials"**: Verify `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` are correct
|
||||||
|
- **"Model not found"**: Check that you've requested access to the models in Bedrock Console
|
||||||
|
- **Re-embedding fails**: Ensure AWS credentials are set and Bedrock access is enabled
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Private / All rights reserved unless explicitly stated otherwise.
|
Private / All rights reserved unless explicitly stated otherwise.
|
||||||
|
|||||||
30
docker-entrypoint.sh
Normal file
30
docker-entrypoint.sh
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Waiting for database to be ready..."
|
||||||
|
# Wait for database to be available (already handled by depends_on in docker-compose, but extra safety)
|
||||||
|
# Use Node.js to check database connectivity since we have pg available
|
||||||
|
until node -e "
|
||||||
|
const { Client } = require('pg');
|
||||||
|
const client = new Client({ connectionString: process.env.DATABASE_URL });
|
||||||
|
client.connect()
|
||||||
|
.then(() => { client.end(); process.exit(0); })
|
||||||
|
.catch(() => { process.exit(1); });
|
||||||
|
" 2>/dev/null; do
|
||||||
|
echo "Database is unavailable - sleeping"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Syncing database schema with Prisma (db push)..."
|
||||||
|
npx prisma db push
|
||||||
|
|
||||||
|
if [ "$RUN_TEMPLATE_SEED_ON_START" = "true" ]; then
|
||||||
|
echo "Seeding predefined email templates..."
|
||||||
|
# Run seed script; if it fails, log the error but continue starting the app
|
||||||
|
if ! npx tsx scripts/seed-templates.ts; then
|
||||||
|
echo "Template seeding failed, continuing without seeded templates."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting application..."
|
||||||
|
exec "$@"
|
||||||
@ -16,6 +16,18 @@ const nextConfig: NextConfig = {
|
|||||||
protocol: "https",
|
protocol: "https",
|
||||||
hostname: "*.myshopify.com",
|
hostname: "*.myshopify.com",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "*.ngrok.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "*.ngrok.io",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "*.ngrok-free.dev",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// Allow ngrok origin for development (fixes cross-origin warnings)
|
// Allow ngrok origin for development (fixes cross-origin warnings)
|
||||||
|
|||||||
2231
package-lock.json
generated
2231
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@ -6,17 +6,21 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"re-embed-knowledge-base": "tsx scripts/re-embed-knowledge-base.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/core": "^0.41.0",
|
"@auth/core": "^0.41.0",
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
|
"@aws-sdk/client-bedrock-runtime": "^3.989.0",
|
||||||
"@prisma/adapter-pg": "^7.3.0",
|
"@prisma/adapter-pg": "^7.3.0",
|
||||||
"@prisma/client": "^7.3.0",
|
"@prisma/client": "^7.3.0",
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/nodemailer": "^7.0.9",
|
"@types/nodemailer": "^7.0.9",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@upstash/ratelimit": "^2.0.5",
|
"@upstash/ratelimit": "^2.0.5",
|
||||||
"@upstash/redis": "^1.34.3",
|
"@upstash/redis": "^1.34.3",
|
||||||
|
"aws-cli": "^0.0.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@ -35,8 +39,7 @@
|
|||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8"
|
||||||
"@tailwindcss/postcss": "^4"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^3.0.0",
|
"@types/bcryptjs": "^3.0.0",
|
||||||
@ -48,6 +51,7 @@
|
|||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
-- Migrate KnowledgeChunk embeddings from 1536 dimensions (Gemini) to 1024 dimensions (Bedrock Titan V2)
|
||||||
|
--
|
||||||
|
-- NOTE: This migration changes the vector dimension. Existing embeddings will need to be
|
||||||
|
-- re-embedded using the re-embedding script after this migration runs.
|
||||||
|
--
|
||||||
|
-- Strategy: Drop the index, change column type (will truncate/pad existing vectors),
|
||||||
|
-- then recreate the index with the new dimension.
|
||||||
|
|
||||||
|
-- Drop the existing index
|
||||||
|
DROP INDEX IF EXISTS "KnowledgeChunk_embedding_storeId_idx";
|
||||||
|
|
||||||
|
-- Change the embedding column dimension from 1536 to 1024
|
||||||
|
-- PostgreSQL will automatically truncate vectors longer than 1024 or pad shorter ones
|
||||||
|
ALTER TABLE "KnowledgeChunk"
|
||||||
|
ALTER COLUMN "embedding" TYPE vector(1024);
|
||||||
|
|
||||||
|
-- Recreate the index with the new dimension
|
||||||
|
CREATE INDEX IF NOT EXISTS "KnowledgeChunk_embedding_storeId_idx"
|
||||||
|
ON "KnowledgeChunk"
|
||||||
|
USING ivfflat ("embedding" vector_cosine_ops)
|
||||||
|
WITH (lists = 100)
|
||||||
|
WHERE "storeId" IS NOT NULL;
|
||||||
@ -109,6 +109,7 @@ model Store {
|
|||||||
faqItems FaqItem[]
|
faqItems FaqItem[]
|
||||||
knowledgeChunks KnowledgeChunk[]
|
knowledgeChunks KnowledgeChunk[]
|
||||||
emailLogs EmailLog[]
|
emailLogs EmailLog[]
|
||||||
|
templates Template[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@ -461,3 +462,23 @@ model KnowledgeChunk {
|
|||||||
@@index([documentId])
|
@@index([documentId])
|
||||||
@@index([faqItemId])
|
@@index([faqItemId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Template {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
storeId String
|
||||||
|
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
name String
|
||||||
|
intent String
|
||||||
|
mood String
|
||||||
|
description String?
|
||||||
|
|
||||||
|
body String // Template body (HTML or plain text with {{variables}})
|
||||||
|
variables Json? // Optional metadata describing expected variables
|
||||||
|
isHtml Boolean @default(true)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([storeId])
|
||||||
|
}
|
||||||
|
|||||||
156
scripts/re-embed-knowledge-base.ts
Normal file
156
scripts/re-embed-knowledge-base.ts
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
/**
|
||||||
|
* Re-embedding script for Knowledge Base
|
||||||
|
*
|
||||||
|
* This script re-embeds all existing KnowledgeChunk records using AWS Bedrock
|
||||||
|
* Titan Text Embeddings V2 (1024 dimensions) to replace Gemini embeddings (1536 dimensions).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npm run re-embed-knowledge-base
|
||||||
|
* or
|
||||||
|
* tsx scripts/re-embed-knowledge-base.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { prisma } from "../src/lib/prisma"
|
||||||
|
import { embedBatch } from "../src/lib/kb/bedrockEmbeddings"
|
||||||
|
import { logger } from "../src/lib/logger"
|
||||||
|
|
||||||
|
// AWS Bedrock Titan V2 limit: 2000 requests/minute
|
||||||
|
// Use smaller batches with delays to stay under limit
|
||||||
|
const BATCH_SIZE = 10 // Process chunks in smaller batches to avoid rate limits
|
||||||
|
|
||||||
|
interface KnowledgeChunk {
|
||||||
|
id: string
|
||||||
|
content: string
|
||||||
|
storeId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reEmbedKnowledgeBase() {
|
||||||
|
try {
|
||||||
|
logger.info("Starting knowledge base re-embedding process...")
|
||||||
|
|
||||||
|
// Fetch all knowledge chunks
|
||||||
|
const sql = `SELECT "id", "content", "storeId"
|
||||||
|
FROM "KnowledgeChunk"
|
||||||
|
WHERE "content" IS NOT NULL AND "content" != ''
|
||||||
|
ORDER BY "createdAt" ASC`
|
||||||
|
const chunks = await prisma.$queryRawUnsafe<KnowledgeChunk[]>(sql)
|
||||||
|
|
||||||
|
if (chunks.length === 0) {
|
||||||
|
logger.info("No knowledge chunks found to re-embed.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Found ${chunks.length} knowledge chunks to re-embed.`)
|
||||||
|
|
||||||
|
let processed = 0
|
||||||
|
let failed = 0
|
||||||
|
const totalBatches = Math.ceil(chunks.length / BATCH_SIZE)
|
||||||
|
|
||||||
|
// Process in batches
|
||||||
|
for (let i = 0; i < chunks.length; i += BATCH_SIZE) {
|
||||||
|
const batch = chunks.slice(i, i + BATCH_SIZE)
|
||||||
|
const batchNumber = Math.floor(i / BATCH_SIZE) + 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Processing batch ${batchNumber}/${totalBatches} (${batch.length} chunks)...`,
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate embeddings for this batch
|
||||||
|
const texts = batch.map((chunk) => chunk.content)
|
||||||
|
const embeddings = await embedBatch(texts)
|
||||||
|
|
||||||
|
if (embeddings.length !== batch.length) {
|
||||||
|
logger.error(
|
||||||
|
`Embedding count mismatch: expected ${batch.length}, got ${embeddings.length}`,
|
||||||
|
)
|
||||||
|
failed += batch.length
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update each chunk with its new embedding
|
||||||
|
const updatePromises = batch.map(async (chunk, index) => {
|
||||||
|
const embedding = embeddings[index]
|
||||||
|
|
||||||
|
if (!embedding || embedding.length === 0) {
|
||||||
|
logger.warn(
|
||||||
|
`Skipping chunk ${chunk.id}: empty embedding generated`,
|
||||||
|
)
|
||||||
|
failed++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embedding.length !== 1024) {
|
||||||
|
logger.warn(
|
||||||
|
`Chunk ${chunk.id}: embedding dimension is ${embedding.length}, expected 1024`,
|
||||||
|
)
|
||||||
|
// Pad or truncate to 1024
|
||||||
|
if (embedding.length < 1024) {
|
||||||
|
embedding.push(...new Array(1024 - embedding.length).fill(0))
|
||||||
|
} else {
|
||||||
|
embedding.splice(1024)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`
|
||||||
|
UPDATE "KnowledgeChunk"
|
||||||
|
SET "embedding" = $1::vector(1024),
|
||||||
|
"updatedAt" = NOW()
|
||||||
|
WHERE "id" = $2
|
||||||
|
`,
|
||||||
|
`[${embedding.join(",")}]`,
|
||||||
|
chunk.id,
|
||||||
|
)
|
||||||
|
processed++
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to update chunk ${chunk.id}:`, error)
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all(updatePromises)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Batch ${batchNumber} completed. Processed: ${processed}, Failed: ${failed}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add delay between batches to avoid rate limiting
|
||||||
|
// With 2s delay per request + batch processing, we need extra time between batches
|
||||||
|
if (i + BATCH_SIZE < chunks.length) {
|
||||||
|
// Wait 5 seconds between batches to ensure we stay under 2000 req/min limit
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5000))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error processing batch ${batchNumber}:`, error)
|
||||||
|
failed += batch.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Re-embedding completed. Processed: ${processed}, Failed: ${failed}, Total: ${chunks.length}`,
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Fatal error during re-embedding:", error)
|
||||||
|
process.exit(1)
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the script
|
||||||
|
if (require.main === module) {
|
||||||
|
reEmbedKnowledgeBase()
|
||||||
|
.then(() => {
|
||||||
|
logger.info("Re-embedding script finished successfully.")
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
logger.error("Re-embedding script failed:", error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { reEmbedKnowledgeBase }
|
||||||
291
scripts/seed-templates.ts
Normal file
291
scripts/seed-templates.ts
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
/**
|
||||||
|
* Seed script for predefined JUNOHUB email templates.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* tsx scripts/seed-templates.ts
|
||||||
|
*
|
||||||
|
* Behaviour:
|
||||||
|
* - Finds a target store:
|
||||||
|
* - If SEED_STORE_ID is set, uses that storeId
|
||||||
|
* - Otherwise, uses the first active store it can find
|
||||||
|
* - Inserts a small library of templates for common intents/moods
|
||||||
|
* - Idempotent: skips creating a template if one with the same
|
||||||
|
* (storeId, intent, mood, name) already exists.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { prisma } from "../src/lib/prisma"
|
||||||
|
import { logger } from "../src/lib/logger"
|
||||||
|
import { randomUUID } from "crypto"
|
||||||
|
|
||||||
|
type SeedTemplate = {
|
||||||
|
name: string
|
||||||
|
intent: string
|
||||||
|
mood: string
|
||||||
|
description: string
|
||||||
|
body: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEED_TEMPLATES: SeedTemplate[] = [
|
||||||
|
// Shipping delay - angry customer
|
||||||
|
{
|
||||||
|
name: "Shipping delay – angry customer",
|
||||||
|
intent: "shipping_delay",
|
||||||
|
mood: "angry",
|
||||||
|
description:
|
||||||
|
"Apology and clear acknowledgement for an angry customer asking where their order is.",
|
||||||
|
body: `Hi {{customer_name}},
|
||||||
|
|
||||||
|
I'm really sorry for the frustration here. I can see why you'd be upset, and I appreciate you taking the time to reach out.
|
||||||
|
|
||||||
|
We've reviewed your message and we're looking into the status of your order right away. {{ticket_summary}}
|
||||||
|
|
||||||
|
As soon as we have an updated timeline from our shipping partner, we'll follow up with you by email. If you have any extra details you think might help, you can reply directly to this message.
|
||||||
|
|
||||||
|
Thank you for your patience while we sort this out.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
{{store_name}} Support Team`,
|
||||||
|
},
|
||||||
|
// Shipping delay - confused / neutral customer
|
||||||
|
{
|
||||||
|
name: "Shipping delay – confused customer",
|
||||||
|
intent: "shipping_delay",
|
||||||
|
mood: "confused",
|
||||||
|
description:
|
||||||
|
"Reassuring response for a customer who is unsure where their order is.",
|
||||||
|
body: `Hi {{customer_name}},
|
||||||
|
|
||||||
|
Thanks for reaching out about your order – I’m glad you checked in.
|
||||||
|
|
||||||
|
We’ve received your message and we’re reviewing your order details now. {{ticket_summary}}
|
||||||
|
|
||||||
|
We’re confirming the latest tracking information with our shipping partner and will follow up with an update as soon as we have it. In the meantime, if you notice any changes or receive a new tracking email, feel free to reply here so we can keep everything in one place.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
{{store_name}} Support Team`,
|
||||||
|
},
|
||||||
|
// Refund / return request – neutral
|
||||||
|
{
|
||||||
|
name: "Refund or return – neutral",
|
||||||
|
intent: "refund_request",
|
||||||
|
mood: "neutral",
|
||||||
|
description:
|
||||||
|
"Professional acknowledgement of a refund/return request with next steps.",
|
||||||
|
body: `Hi {{customer_name}},
|
||||||
|
|
||||||
|
Thank you for contacting us about your refund/return request.
|
||||||
|
|
||||||
|
We’ve reviewed your message and we’re now looking into the order details so we can process this correctly. {{ticket_summary}}
|
||||||
|
|
||||||
|
In some cases we may need a bit more information (for example photos of the item or confirmation of the order number). If that’s needed, we’ll reply back with a short checklist so you know exactly what to send.
|
||||||
|
|
||||||
|
Once everything is confirmed, we’ll process your refund/return and email you a final confirmation.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
{{store_name}} Support Team`,
|
||||||
|
},
|
||||||
|
// Refund / return request – appreciative / happy
|
||||||
|
{
|
||||||
|
name: "Refund or return – polite customer",
|
||||||
|
intent: "refund_request",
|
||||||
|
mood: "happy",
|
||||||
|
description:
|
||||||
|
"Warm, appreciative reply when the customer is polite and reasonable about a refund/return.",
|
||||||
|
body: `Hi {{customer_name}},
|
||||||
|
|
||||||
|
Thank you for your thoughtful message – we really appreciate how clearly you explained the situation.
|
||||||
|
|
||||||
|
We’ve started reviewing your request and the details of your order. {{ticket_summary}}
|
||||||
|
|
||||||
|
If we need anything else from you (for example photos or a bit more context), we’ll let you know right away. Otherwise, we’ll process your refund/return and follow up with a confirmation email as soon as it’s completed.
|
||||||
|
|
||||||
|
Thank you again for shopping with {{store_name}}.
|
||||||
|
|
||||||
|
Warm regards,
|
||||||
|
{{store_name}} Support Team`,
|
||||||
|
},
|
||||||
|
// Product question / general support – neutral
|
||||||
|
{
|
||||||
|
name: "Product question – we’re checking",
|
||||||
|
intent: "question",
|
||||||
|
mood: "neutral",
|
||||||
|
description:
|
||||||
|
"Neutral response letting the customer know their product question is being reviewed.",
|
||||||
|
body: `Hi {{customer_name}},
|
||||||
|
|
||||||
|
Thanks for reaching out with your question – we’re glad you asked.
|
||||||
|
|
||||||
|
We’ve received your message and we’re checking the details so we can give you a clear answer. {{ticket_summary}}
|
||||||
|
|
||||||
|
As soon as we’ve confirmed everything with the relevant team, we’ll follow up with a detailed reply. If you have any extra information or screenshots that might help, you can reply to this email and they’ll be added to your ticket.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
{{store_name}} Support Team`,
|
||||||
|
},
|
||||||
|
// Product question – confused customer
|
||||||
|
{
|
||||||
|
name: "Product question – confused customer",
|
||||||
|
intent: "question",
|
||||||
|
mood: "confused",
|
||||||
|
description:
|
||||||
|
"Reassuring answer for a customer who is confused about how something works.",
|
||||||
|
body: `Hi {{customer_name}},
|
||||||
|
|
||||||
|
Thank you for reaching out and for explaining what’s been confusing – you’re not alone, and we’re here to help.
|
||||||
|
|
||||||
|
We’ve received your message and we’re reviewing the details so we can walk you through this step by step. {{ticket_summary}}
|
||||||
|
|
||||||
|
Once we’ve double-checked everything, we’ll follow up with a clear explanation and, if helpful, some simple steps you can follow. If you have any screenshots or additional details, feel free to reply to this email so we can see exactly what you’re seeing.
|
||||||
|
|
||||||
|
Warm regards,
|
||||||
|
{{store_name}} Support Team`,
|
||||||
|
},
|
||||||
|
// Account / billing issue – frustrated customer
|
||||||
|
{
|
||||||
|
name: "Account or billing – concerned customer",
|
||||||
|
intent: "account_billing",
|
||||||
|
mood: "angry",
|
||||||
|
description:
|
||||||
|
"Empathetic response when a customer is upset about a billing or account issue.",
|
||||||
|
body: `Hi {{customer_name}},
|
||||||
|
|
||||||
|
I’m really sorry for the worry and frustration this billing issue has caused – I completely understand how important this is.
|
||||||
|
|
||||||
|
We’ve received your message and we’re reviewing your account and recent charges as a priority. {{ticket_summary}}
|
||||||
|
|
||||||
|
If we need any additional details to resolve this, we’ll let you know exactly what we need and why. Otherwise, we’ll follow up as soon as we have a full explanation and next steps for you.
|
||||||
|
|
||||||
|
Thank you for your patience while we get to the bottom of this.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
{{store_name}} Support Team`,
|
||||||
|
},
|
||||||
|
// Account / billing issue – neutral
|
||||||
|
{
|
||||||
|
name: "Account or billing – investigation",
|
||||||
|
intent: "account_billing",
|
||||||
|
mood: "neutral",
|
||||||
|
description:
|
||||||
|
"Calm, neutral response acknowledging an account or billing question and starting an investigation.",
|
||||||
|
body: `Hi {{customer_name}},
|
||||||
|
|
||||||
|
Thanks for getting in touch about your account/billing question.
|
||||||
|
|
||||||
|
We’ve received your message and we’re reviewing your account details and recent activity. {{ticket_summary}}
|
||||||
|
|
||||||
|
If we need any additional information from you, we’ll reply with a short list so it’s easy to provide. Once we’ve completed our review, we’ll follow up with a clear explanation of what we found and, if needed, any adjustments we’ll make.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
{{store_name}} Support Team`,
|
||||||
|
},
|
||||||
|
// Thank-you / positive feedback – happy customer
|
||||||
|
{
|
||||||
|
name: "Thank-you – happy customer",
|
||||||
|
intent: "general",
|
||||||
|
mood: "happy",
|
||||||
|
description:
|
||||||
|
"Warm reply to a customer who sends positive feedback or a thank-you message.",
|
||||||
|
body: `Hi {{customer_name}},
|
||||||
|
|
||||||
|
Thank you so much for your kind message – it truly made our day here at {{store_name}}.
|
||||||
|
|
||||||
|
We really appreciate you taking the time to share your experience. {{ticket_summary}}
|
||||||
|
|
||||||
|
If there’s ever anything else we can help with, just reply to this email and we’ll be glad to assist.
|
||||||
|
|
||||||
|
Warm regards,
|
||||||
|
{{store_name}} Support Team`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
async function getTargetStoreId(): Promise<string> {
|
||||||
|
const explicitId = process.env.SEED_STORE_ID
|
||||||
|
if (explicitId) {
|
||||||
|
const store = await prisma.store.findUnique({
|
||||||
|
where: { id: explicitId },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!store) {
|
||||||
|
throw new Error(`SEED_STORE_ID is set, but no store found with id=${explicitId}`)
|
||||||
|
}
|
||||||
|
return store.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await prisma.store.findFirst({
|
||||||
|
where: { isActive: true },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
throw new Error(
|
||||||
|
"No active store found. Set SEED_STORE_ID to a valid store id before running this script.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return store.id
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedTemplates() {
|
||||||
|
try {
|
||||||
|
logger.info("Starting template seeding...")
|
||||||
|
|
||||||
|
const storeId = await getTargetStoreId()
|
||||||
|
logger.info(`Seeding templates for storeId=${storeId}`)
|
||||||
|
|
||||||
|
for (const tpl of SEED_TEMPLATES) {
|
||||||
|
// Check if a template with the same (storeId, intent, mood, name) already exists
|
||||||
|
const existing = await prisma.$queryRaw<
|
||||||
|
{ id: string }[]
|
||||||
|
>`
|
||||||
|
SELECT "id"
|
||||||
|
FROM "Template"
|
||||||
|
WHERE "storeId" = ${storeId}
|
||||||
|
AND "intent" = ${tpl.intent}
|
||||||
|
AND "mood" = ${tpl.mood}
|
||||||
|
AND "name" = ${tpl.name}
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
logger.info(
|
||||||
|
`Skipping existing template: "${tpl.name}" (intent=${tpl.intent}, mood=${tpl.mood})`,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = randomUUID()
|
||||||
|
|
||||||
|
await prisma.$executeRaw`
|
||||||
|
INSERT INTO "Template"
|
||||||
|
("id", "storeId", "name", "intent", "mood", "description", "body", "variables", "isHtml", "createdAt", "updatedAt")
|
||||||
|
VALUES
|
||||||
|
(${id}, ${storeId}, ${tpl.name}, ${tpl.intent}, ${tpl.mood}, ${tpl.description}, ${tpl.body}, NULL, FALSE, NOW(), NOW())
|
||||||
|
`
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Inserted template: "${tpl.name}" (intent=${tpl.intent}, mood=${tpl.mood})`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Template seeding completed.")
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Template seeding failed:", error)
|
||||||
|
process.exit(1)
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
seedTemplates()
|
||||||
|
.then(() => {
|
||||||
|
logger.info("Seed script finished successfully.")
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
logger.error("Seed script encountered an error:", error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,8 +1,12 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { startGmailPoller } from "@/lib/gmail/poller"
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
// Start the background Gmail poller (idempotent - safe to call repeatedly)
|
||||||
|
startGmailPoller()
|
||||||
|
|
||||||
// Simple database connectivity check
|
// Simple database connectivity check
|
||||||
await prisma.$queryRaw`SELECT 1`
|
await prisma.$queryRaw`SELECT 1`
|
||||||
|
|
||||||
|
|||||||
@ -10,8 +10,13 @@ export async function GET(req: Request) {
|
|||||||
try {
|
try {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
|
const baseUrl =
|
||||||
|
process.env.AUTH_URL ||
|
||||||
|
process.env.NEXTAUTH_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL ||
|
||||||
|
"http://localhost:3000"
|
||||||
return NextResponse.redirect(
|
return NextResponse.redirect(
|
||||||
new URL("/login?error=unauthorized", process.env.NEXTAUTH_URL || "http://localhost:3000")
|
new URL("/login?error=unauthorized", baseUrl)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -23,20 +28,34 @@ export async function GET(req: Request) {
|
|||||||
// Handle OAuth errors
|
// Handle OAuth errors
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("Gmail OAuth error:", error)
|
console.error("Gmail OAuth error:", error)
|
||||||
|
const baseUrl =
|
||||||
|
process.env.AUTH_URL ||
|
||||||
|
process.env.NEXTAUTH_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL ||
|
||||||
|
"http://localhost:3000"
|
||||||
return NextResponse.redirect(
|
return NextResponse.redirect(
|
||||||
new URL("/dashboard/juno-engine/gmail-connect?error=oauth_failed", process.env.NEXTAUTH_URL || "http://localhost:3000")
|
new URL("/dashboard/juno-engine/gmail-connect?error=oauth_failed", baseUrl)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
|
const baseUrl =
|
||||||
|
process.env.AUTH_URL ||
|
||||||
|
process.env.NEXTAUTH_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL ||
|
||||||
|
"http://localhost:3000"
|
||||||
return NextResponse.redirect(
|
return NextResponse.redirect(
|
||||||
new URL("/dashboard/juno-engine/gmail-connect?error=no_code", process.env.NEXTAUTH_URL || "http://localhost:3000")
|
new URL("/dashboard/juno-engine/gmail-connect?error=no_code", baseUrl)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exchange authorization code for tokens
|
// Exchange authorization code for tokens
|
||||||
// IMPORTANT: This redirect URI must exactly match what was sent in the authorization request
|
// IMPORTANT: This redirect URI must exactly match what was sent in the authorization request
|
||||||
const baseUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"
|
const baseUrl =
|
||||||
|
process.env.AUTH_URL ||
|
||||||
|
process.env.NEXTAUTH_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL ||
|
||||||
|
"http://localhost:3000"
|
||||||
const redirectUri = `${baseUrl}/api/integrations/gmail/callback`
|
const redirectUri = `${baseUrl}/api/integrations/gmail/callback`
|
||||||
|
|
||||||
console.log("Gmail OAuth Callback - Redirect URI:", redirectUri) // Log for debugging
|
console.log("Gmail OAuth Callback - Redirect URI:", redirectUri) // Log for debugging
|
||||||
@ -62,16 +81,26 @@ export async function GET(req: Request) {
|
|||||||
|
|
||||||
// Provide more specific error message if it's a redirect_uri_mismatch
|
// Provide more specific error message if it's a redirect_uri_mismatch
|
||||||
if (errorData.error === "redirect_uri_mismatch") {
|
if (errorData.error === "redirect_uri_mismatch") {
|
||||||
|
const baseUrlForError =
|
||||||
|
process.env.AUTH_URL ||
|
||||||
|
process.env.NEXTAUTH_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL ||
|
||||||
|
"http://localhost:3000"
|
||||||
return NextResponse.redirect(
|
return NextResponse.redirect(
|
||||||
new URL(
|
new URL(
|
||||||
`/dashboard/juno-engine/gmail-connect?error=redirect_uri_mismatch&redirect_uri=${encodeURIComponent(redirectUri)}`,
|
`/dashboard/juno-engine/gmail-connect?error=redirect_uri_mismatch&redirect_uri=${encodeURIComponent(redirectUri)}`,
|
||||||
process.env.NEXTAUTH_URL || "http://localhost:3000"
|
baseUrlForError
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const baseUrlForError =
|
||||||
|
process.env.AUTH_URL ||
|
||||||
|
process.env.NEXTAUTH_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL ||
|
||||||
|
"http://localhost:3000"
|
||||||
return NextResponse.redirect(
|
return NextResponse.redirect(
|
||||||
new URL("/dashboard/juno-engine/gmail-connect?error=token_exchange_failed", process.env.NEXTAUTH_URL || "http://localhost:3000")
|
new URL("/dashboard/juno-engine/gmail-connect?error=token_exchange_failed", baseUrlForError)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,15 +142,25 @@ export async function GET(req: Request) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching user info:", error)
|
console.error("Error fetching user info:", error)
|
||||||
|
const baseUrlForError =
|
||||||
|
process.env.AUTH_URL ||
|
||||||
|
process.env.NEXTAUTH_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL ||
|
||||||
|
"http://localhost:3000"
|
||||||
return NextResponse.redirect(
|
return NextResponse.redirect(
|
||||||
new URL("/dashboard/juno-engine/gmail-connect?error=user_info_failed", process.env.NEXTAUTH_URL || "http://localhost:3000")
|
new URL("/dashboard/juno-engine/gmail-connect?error=user_info_failed", baseUrlForError)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userInfo?.email) {
|
if (!userInfo?.email) {
|
||||||
console.error("No email found in user info")
|
console.error("No email found in user info")
|
||||||
|
const baseUrlForError =
|
||||||
|
process.env.AUTH_URL ||
|
||||||
|
process.env.NEXTAUTH_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL ||
|
||||||
|
"http://localhost:3000"
|
||||||
return NextResponse.redirect(
|
return NextResponse.redirect(
|
||||||
new URL("/dashboard/juno-engine/gmail-connect?error=user_info_failed", process.env.NEXTAUTH_URL || "http://localhost:3000")
|
new URL("/dashboard/juno-engine/gmail-connect?error=user_info_failed", baseUrlForError)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -151,13 +190,23 @@ export async function GET(req: Request) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const baseUrlDone =
|
||||||
|
process.env.AUTH_URL ||
|
||||||
|
process.env.NEXTAUTH_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL ||
|
||||||
|
"http://localhost:3000"
|
||||||
return NextResponse.redirect(
|
return NextResponse.redirect(
|
||||||
new URL("/dashboard/juno-engine/gmail-connect?connected=true", process.env.NEXTAUTH_URL || "http://localhost:3000")
|
new URL("/dashboard/juno-engine/gmail-connect?connected=true", baseUrlDone)
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Gmail callback error:", error)
|
console.error("Gmail callback error:", error)
|
||||||
|
const baseUrlError =
|
||||||
|
process.env.AUTH_URL ||
|
||||||
|
process.env.NEXTAUTH_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL ||
|
||||||
|
"http://localhost:3000"
|
||||||
return NextResponse.redirect(
|
return NextResponse.redirect(
|
||||||
new URL("/dashboard/juno-engine/gmail-connect?error=callback_failed", process.env.NEXTAUTH_URL || "http://localhost:3000")
|
new URL("/dashboard/juno-engine/gmail-connect?error=callback_failed", baseUrlError)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,7 +38,11 @@ export async function POST() {
|
|||||||
// For production: https://yourdomain.com/api/integrations/gmail/callback
|
// For production: https://yourdomain.com/api/integrations/gmail/callback
|
||||||
// For development: http://localhost:3000/api/integrations/gmail/callback
|
// For development: http://localhost:3000/api/integrations/gmail/callback
|
||||||
// For ngrok: https://your-ngrok-url.ngrok-free.dev/api/integrations/gmail/callback
|
// For ngrok: https://your-ngrok-url.ngrok-free.dev/api/integrations/gmail/callback
|
||||||
const baseUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"
|
const baseUrl =
|
||||||
|
process.env.AUTH_URL ||
|
||||||
|
process.env.NEXTAUTH_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL ||
|
||||||
|
"http://localhost:3000"
|
||||||
const redirectUri = `${baseUrl}/api/integrations/gmail/callback`
|
const redirectUri = `${baseUrl}/api/integrations/gmail/callback`
|
||||||
|
|
||||||
console.log("Gmail OAuth - Redirect URI:", redirectUri) // Log for debugging
|
console.log("Gmail OAuth - Redirect URI:", redirectUri) // Log for debugging
|
||||||
|
|||||||
158
src/app/api/integrations/gmail/watch/route.ts
Normal file
158
src/app/api/integrations/gmail/watch/route.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { logger } from "@/lib/logger"
|
||||||
|
import { getGmailAccessToken } from "@/lib/gmail"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/integrations/gmail/watch
|
||||||
|
* Set up Gmail push notifications (watch) for a user's mailbox
|
||||||
|
*
|
||||||
|
* This creates a watch request that tells Gmail to send push notifications
|
||||||
|
* to the webhook endpoint when new emails arrive.
|
||||||
|
*
|
||||||
|
* Note: Gmail watch requests expire after 7 days and need to be renewed.
|
||||||
|
*/
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Gmail access token
|
||||||
|
const accessToken = await getGmailAccessToken(session.user.id)
|
||||||
|
if (!accessToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Gmail not connected. Please connect your Gmail account first." },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get webhook URL
|
||||||
|
const baseUrl =
|
||||||
|
process.env.AUTH_URL ||
|
||||||
|
process.env.NEXTAUTH_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL ||
|
||||||
|
"http://localhost:3000"
|
||||||
|
const webhookUrl = `${baseUrl}/api/webhooks/gmail`
|
||||||
|
|
||||||
|
// Get user's email from integration
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const integration = await (prisma as any).gmailIntegration.findUnique({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
select: { email: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!integration?.email) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Gmail email not found" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create watch request
|
||||||
|
// Gmail will send push notifications to the webhook URL
|
||||||
|
const watchResponse = await fetch(
|
||||||
|
"https://gmail.googleapis.com/gmail/v1/users/me/watch",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
topicName: process.env.GMAIL_PUBSUB_TOPIC || "projects/your-project/topics/gmail-notifications",
|
||||||
|
labelIds: ["INBOX"], // Watch for emails in inbox
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!watchResponse.ok) {
|
||||||
|
const errorData = await watchResponse.json().catch(() => ({}))
|
||||||
|
logger.error("Failed to set up Gmail watch", { error: errorData })
|
||||||
|
|
||||||
|
// If topic doesn't exist, provide helpful error message
|
||||||
|
if (watchResponse.status === 400) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
message: "Gmail Pub/Sub topic not configured. Please set up Google Cloud Pub/Sub first.",
|
||||||
|
error: errorData
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to set up Gmail watch", error: errorData },
|
||||||
|
{ status: watchResponse.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const watchData = await watchResponse.json()
|
||||||
|
const expiration = watchData.expiration ? new Date(parseInt(watchData.expiration)).toISOString() : null
|
||||||
|
|
||||||
|
logger.info("Gmail watch set up successfully", {
|
||||||
|
userId: session.user.id,
|
||||||
|
email: integration.email,
|
||||||
|
expiration
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "Gmail push notifications enabled",
|
||||||
|
expiration,
|
||||||
|
webhookUrl
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error setting up Gmail watch", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to set up Gmail watch" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/integrations/gmail/watch
|
||||||
|
* Check watch status
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = await getGmailAccessToken(session.user.id)
|
||||||
|
if (!accessToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Gmail not connected" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop any existing watch (optional - just to check status)
|
||||||
|
const stopResponse = await fetch(
|
||||||
|
"https://gmail.googleapis.com/gmail/v1/users/me/stop",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
watchActive: !stopResponse.ok, // If stop fails, watch might be active
|
||||||
|
message: stopResponse.ok ? "Watch stopped" : "Watch may be active"
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error checking Gmail watch status", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to check watch status" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
246
src/app/api/juno-engine/templates/[templateId]/route.ts
Normal file
246
src/app/api/juno-engine/templates/[templateId]/route.ts
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { logger } from "@/lib/logger"
|
||||||
|
|
||||||
|
type TemplateRow = {
|
||||||
|
id: string
|
||||||
|
storeId: string
|
||||||
|
name: string
|
||||||
|
intent: string
|
||||||
|
mood: string
|
||||||
|
description: string | null
|
||||||
|
body: string
|
||||||
|
variables: unknown | null
|
||||||
|
isHtml: boolean
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getActiveStoreId(userId: string) {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: {
|
||||||
|
stores: {
|
||||||
|
where: { isActive: true },
|
||||||
|
take: 1,
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return user?.stores[0]?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ templateId: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user.role !== "VENDOR") {
|
||||||
|
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { templateId } = await params
|
||||||
|
|
||||||
|
const storeId = await getActiveStoreId(session.user.id)
|
||||||
|
if (!storeId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "No active store found" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRaw<TemplateRow[]>`
|
||||||
|
SELECT
|
||||||
|
"id",
|
||||||
|
"storeId",
|
||||||
|
"name",
|
||||||
|
"intent",
|
||||||
|
"mood",
|
||||||
|
"description",
|
||||||
|
"body",
|
||||||
|
"variables",
|
||||||
|
"isHtml",
|
||||||
|
"updatedAt"
|
||||||
|
FROM "Template"
|
||||||
|
WHERE "id" = ${templateId} AND "storeId" = ${storeId}
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
const row = rows[0]
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return NextResponse.json({ message: "Template not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = {
|
||||||
|
...row,
|
||||||
|
variables: row.variables ?? undefined,
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ template })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to load template", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to load template" },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ templateId: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user.role !== "VENDOR") {
|
||||||
|
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { templateId } = await params
|
||||||
|
|
||||||
|
const storeId = await getActiveStoreId(session.user.id)
|
||||||
|
if (!storeId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "No active store found" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRows = await prisma.$queryRaw<Pick<TemplateRow, "id">[]>`
|
||||||
|
SELECT "id"
|
||||||
|
FROM "Template"
|
||||||
|
WHERE "id" = ${templateId} AND "storeId" = ${storeId}
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
const existing = existingRows[0]
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json({ message: "Template not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => null)
|
||||||
|
if (!body) {
|
||||||
|
return NextResponse.json({ message: "Invalid JSON body" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
intent,
|
||||||
|
mood,
|
||||||
|
description,
|
||||||
|
body: templateBody,
|
||||||
|
variables,
|
||||||
|
isHtml,
|
||||||
|
} = body as {
|
||||||
|
name?: string
|
||||||
|
intent?: string
|
||||||
|
mood?: string
|
||||||
|
description?: string | null
|
||||||
|
body?: string
|
||||||
|
variables?: unknown
|
||||||
|
isHtml?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRaw<TemplateRow[]>`
|
||||||
|
UPDATE "Template"
|
||||||
|
SET
|
||||||
|
"name" = COALESCE(${name}, "name"),
|
||||||
|
"intent" = COALESCE(${intent}, "intent"),
|
||||||
|
"mood" = COALESCE(${mood}, "mood"),
|
||||||
|
"description" = COALESCE(${description ?? null}, "description"),
|
||||||
|
"body" = COALESCE(${templateBody}, "body"),
|
||||||
|
"variables" = COALESCE(${variables as any}, "variables"),
|
||||||
|
"isHtml" = COALESCE(${isHtml}, "isHtml"),
|
||||||
|
"updatedAt" = NOW()
|
||||||
|
WHERE "id" = ${existing.id}
|
||||||
|
RETURNING
|
||||||
|
"id",
|
||||||
|
"storeId",
|
||||||
|
"name",
|
||||||
|
"intent",
|
||||||
|
"mood",
|
||||||
|
"description",
|
||||||
|
"body",
|
||||||
|
"variables",
|
||||||
|
"isHtml",
|
||||||
|
"updatedAt"
|
||||||
|
`
|
||||||
|
|
||||||
|
const row = rows[0]
|
||||||
|
const updated = {
|
||||||
|
...row,
|
||||||
|
variables: row.variables ?? undefined,
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ template: updated })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to update template", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to update template" },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ templateId: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user.role !== "VENDOR") {
|
||||||
|
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { templateId } = await params
|
||||||
|
|
||||||
|
const storeId = await getActiveStoreId(session.user.id)
|
||||||
|
if (!storeId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "No active store found" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRows = await prisma.$queryRaw<Pick<TemplateRow, "id">[]>`
|
||||||
|
SELECT "id"
|
||||||
|
FROM "Template"
|
||||||
|
WHERE "id" = ${templateId} AND "storeId" = ${storeId}
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
const existing = existingRows[0]
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json({ message: "Template not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$executeRaw`
|
||||||
|
DELETE FROM "Template"
|
||||||
|
WHERE "id" = ${existing.id}
|
||||||
|
`
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to delete template", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to delete template" },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
184
src/app/api/juno-engine/templates/route.ts
Normal file
184
src/app/api/juno-engine/templates/route.ts
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { logger } from "@/lib/logger"
|
||||||
|
import { randomUUID } from "crypto"
|
||||||
|
|
||||||
|
type TemplateRow = {
|
||||||
|
id: string
|
||||||
|
storeId: string
|
||||||
|
name: string
|
||||||
|
intent: string
|
||||||
|
mood: string
|
||||||
|
description: string | null
|
||||||
|
body: string
|
||||||
|
variables: unknown | null
|
||||||
|
isHtml: boolean
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user.role !== "VENDOR") {
|
||||||
|
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(req.url)
|
||||||
|
const intent = url.searchParams.get("intent") || undefined
|
||||||
|
const mood = url.searchParams.get("mood") || undefined
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
include: {
|
||||||
|
stores: {
|
||||||
|
where: { isActive: true },
|
||||||
|
take: 1,
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeStore = user?.stores[0]
|
||||||
|
if (!activeStore) {
|
||||||
|
return NextResponse.json({ templates: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use raw SQL so we don't depend on generated Prisma delegates
|
||||||
|
const rows = await prisma.$queryRaw<TemplateRow[]>`
|
||||||
|
SELECT
|
||||||
|
"id",
|
||||||
|
"storeId",
|
||||||
|
"name",
|
||||||
|
"intent",
|
||||||
|
"mood",
|
||||||
|
"description",
|
||||||
|
"body",
|
||||||
|
"variables",
|
||||||
|
"isHtml",
|
||||||
|
"updatedAt"
|
||||||
|
FROM "Template"
|
||||||
|
WHERE "storeId" = ${activeStore.id}
|
||||||
|
ORDER BY "updatedAt" DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
const filtered =
|
||||||
|
intent || mood
|
||||||
|
? rows.filter((tpl: TemplateRow) => {
|
||||||
|
if (intent && tpl.intent !== intent) return false
|
||||||
|
if (mood && tpl.mood !== mood) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
: rows
|
||||||
|
|
||||||
|
const templates = filtered.map((tpl: TemplateRow) => ({
|
||||||
|
...tpl,
|
||||||
|
variables: tpl.variables ?? undefined,
|
||||||
|
updatedAt: tpl.updatedAt.toISOString(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return NextResponse.json({ templates })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to load templates", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to load templates", templates: [] },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user.role !== "VENDOR") {
|
||||||
|
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
include: {
|
||||||
|
stores: {
|
||||||
|
where: { isActive: true },
|
||||||
|
take: 1,
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeStore = user?.stores[0]
|
||||||
|
if (!activeStore) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "No active store found" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => null)
|
||||||
|
if (!body) {
|
||||||
|
return NextResponse.json({ message: "Invalid JSON body" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, intent, mood, description, body: templateBody, variables, isHtml } =
|
||||||
|
body as {
|
||||||
|
name?: string
|
||||||
|
intent?: string
|
||||||
|
mood?: string
|
||||||
|
description?: string | null
|
||||||
|
body?: string
|
||||||
|
variables?: unknown
|
||||||
|
isHtml?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name || !intent || !mood || !templateBody) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Missing required fields (name, intent, mood, body)" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = randomUUID()
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRaw<TemplateRow[]>`
|
||||||
|
INSERT INTO "Template"
|
||||||
|
("id", "storeId", "name", "intent", "mood", "description", "body", "variables", "isHtml", "createdAt", "updatedAt")
|
||||||
|
VALUES
|
||||||
|
(${id}, ${activeStore.id}, ${name}, ${intent}, ${mood}, ${description ?? null}, ${templateBody}, ${variables as
|
||||||
|
any}, ${isHtml ?? true}, NOW(), NOW())
|
||||||
|
RETURNING
|
||||||
|
"id",
|
||||||
|
"storeId",
|
||||||
|
"name",
|
||||||
|
"intent",
|
||||||
|
"mood",
|
||||||
|
"description",
|
||||||
|
"body",
|
||||||
|
"variables",
|
||||||
|
"isHtml",
|
||||||
|
"updatedAt"
|
||||||
|
`
|
||||||
|
|
||||||
|
const inserted = rows[0]
|
||||||
|
const template = {
|
||||||
|
...inserted,
|
||||||
|
variables: inserted.variables ?? undefined,
|
||||||
|
updatedAt: inserted.updatedAt.toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ template }, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to create template", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to create template" },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -80,7 +80,10 @@ export async function GET() {
|
|||||||
"sentAt"
|
"sentAt"
|
||||||
FROM "EmailLog"
|
FROM "EmailLog"
|
||||||
WHERE "storeId" = ${activeStore.id}
|
WHERE "storeId" = ${activeStore.id}
|
||||||
AND "isAutomated" = TRUE
|
AND (
|
||||||
|
"isAutomated" = TRUE
|
||||||
|
OR "trigger" = 'GMAIL_INCOMING'
|
||||||
|
)
|
||||||
ORDER BY "sentAt" DESC
|
ORDER BY "sentAt" DESC
|
||||||
LIMIT 200
|
LIMIT 200
|
||||||
`
|
`
|
||||||
|
|||||||
@ -79,12 +79,15 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Register Webhooks with Shopify
|
// Register Webhooks with Shopify
|
||||||
const appUrl = process.env.NEXTAUTH_URL || "https://your-tunnel-url.ngrok-free.app"
|
const appUrl = process.env.AUTH_URL || process.env.NEXTAUTH_URL || "https://your-tunnel-url.ngrok-free.app"
|
||||||
const webhookTopics = ["orders/create", "orders/updated", "products/create", "products/update", "products/delete"]
|
const webhookTopics = ["orders/create", "orders/updated", "products/create", "products/update", "products/delete"]
|
||||||
|
|
||||||
for (const topic of webhookTopics) {
|
for (const topic of webhookTopics) {
|
||||||
try {
|
try {
|
||||||
await fetch(`https://${cleanDomain}/admin/api/2024-01/webhooks.json`, {
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 10000) // 10 second timeout
|
||||||
|
|
||||||
|
const response = await fetch(`https://${cleanDomain}/admin/api/2024-01/webhooks.json`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"X-Shopify-Access-Token": accessToken,
|
"X-Shopify-Access-Token": accessToken,
|
||||||
@ -96,11 +99,24 @@ export async function POST(req: NextRequest) {
|
|||||||
address: `${appUrl}/api/webhooks/shopify`,
|
address: `${appUrl}/api/webhooks/shopify`,
|
||||||
format: "json"
|
format: "json"
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
|
signal: controller.signal
|
||||||
})
|
})
|
||||||
logger.info(`Registered Shopify webhook: ${topic}`)
|
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text().catch(() => "")
|
||||||
|
logger.warn(`Webhook ${topic} registration returned ${response.status}: ${errorText}`)
|
||||||
|
} else {
|
||||||
|
logger.info(`Registered Shopify webhook: ${topic}`)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`Failed to register webhook ${topic}`, err)
|
if (err instanceof Error && err.name === 'AbortError') {
|
||||||
|
logger.error(`Webhook ${topic} registration timed out`)
|
||||||
|
} else {
|
||||||
|
logger.error(`Failed to register webhook ${topic}`, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { NextResponse } from "next/server"
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { logger } from "@/lib/logger"
|
import { logger } from "@/lib/logger"
|
||||||
import { embedBatch } from "@/lib/kb/geminiEmbeddings"
|
import { embedBatch } from "@/lib/kb/bedrockEmbeddings"
|
||||||
|
|
||||||
type FaqImportItem = {
|
type FaqImportItem = {
|
||||||
question: string
|
question: string
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { NextResponse } from "next/server"
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { logger } from "@/lib/logger"
|
import { logger } from "@/lib/logger"
|
||||||
import { embedBatch } from "@/lib/kb/geminiEmbeddings"
|
import { embedBatch } from "@/lib/kb/bedrockEmbeddings"
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: Request,
|
_request: Request,
|
||||||
|
|||||||
@ -2,11 +2,8 @@ import { NextResponse } from "next/server"
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { logger } from "@/lib/logger"
|
import { logger } from "@/lib/logger"
|
||||||
import { embedBatch } from "@/lib/kb/geminiEmbeddings"
|
import { embedBatch } from "@/lib/kb/bedrockEmbeddings"
|
||||||
|
import { chatCompletion } from "@/lib/kb/bedrockChat"
|
||||||
const GEMINI_API_KEY = process.env.GEMINI_API_KEY
|
|
||||||
const GEMINI_MODEL =
|
|
||||||
process.env.GEMINI_CHAT_MODEL || "models/gemini-1.5-flash-latest"
|
|
||||||
|
|
||||||
type QueryBody = {
|
type QueryBody = {
|
||||||
query?: string
|
query?: string
|
||||||
@ -124,85 +121,28 @@ export async function POST(
|
|||||||
|
|
||||||
const context = contextPieces.join("\n\n---\n\n")
|
const context = contextPieces.join("\n\n---\n\n")
|
||||||
|
|
||||||
if (!GEMINI_API_KEY) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"GEMINI_API_KEY is not configured, cannot generate an AI answer.",
|
|
||||||
context,
|
|
||||||
sources: chunks.map((chunk) => ({
|
|
||||||
id: chunk.id,
|
|
||||||
type: chunk.sourceType,
|
|
||||||
documentId: chunk.documentId,
|
|
||||||
faqId: chunk.faqItemId,
|
|
||||||
similarity: chunk.similarity,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{ status: 500 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const systemPrompt =
|
const systemPrompt =
|
||||||
"You are an AI assistant helping answer customer and vendor questions for a specific store.\n" +
|
"You are an AI assistant helping answer customer and vendor questions for a specific store.\n" +
|
||||||
"Use ONLY the provided context from the store's documents and FAQs.\n" +
|
"Use ONLY the provided context from the store's documents and FAQs.\n" +
|
||||||
"If the answer is not clearly present in the context, say that you do not know.\n" +
|
"If the answer is not clearly present in the context, say that you do not know.\n" +
|
||||||
"Be concise and helpful.\n"
|
"Be concise and helpful.\n"
|
||||||
|
|
||||||
const response = await fetch(
|
const userMessage = `Context:\n${context}\n\nQuestion:\n${question}`
|
||||||
"https://generativelanguage.googleapis.com/v1beta/models/" +
|
|
||||||
encodeURIComponent(GEMINI_MODEL) +
|
|
||||||
":generateContent",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-goog-api-key": GEMINI_API_KEY,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: GEMINI_MODEL,
|
|
||||||
contents: [
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
text:
|
|
||||||
systemPrompt +
|
|
||||||
"\n\nContext:\n" +
|
|
||||||
context +
|
|
||||||
"\n\nQuestion:\n" +
|
|
||||||
question,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
let answer: string
|
||||||
const errorText = await response.text().catch(() => "")
|
try {
|
||||||
logger.error("Gemini RAG answer error", {
|
answer = await chatCompletion(userMessage, {
|
||||||
status: response.status,
|
systemPrompt,
|
||||||
body: errorText,
|
maxTokens: 512,
|
||||||
})
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Bedrock RAG answer error", error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ message: "Failed to generate answer from Gemini" },
|
{ message: "Failed to generate answer from Bedrock" },
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = (await response.json()) as {
|
|
||||||
candidates?: Array<{
|
|
||||||
content?: { parts?: Array<{ text?: string }> }
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
const answer =
|
|
||||||
json.candidates?.[0]?.content?.parts
|
|
||||||
?.map((p) => p.text || "")
|
|
||||||
.join("")
|
|
||||||
.trim() || "I am unable to generate an answer at this time."
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
answer,
|
answer,
|
||||||
sources: chunks.map((chunk) => ({
|
sources: chunks.map((chunk) => ({
|
||||||
|
|||||||
119
src/app/api/webhooks/gmail/route.ts
Normal file
119
src/app/api/webhooks/gmail/route.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { logger } from "@/lib/logger"
|
||||||
|
import { processIncomingEmail } from "@/lib/gmail/emailProcessor"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/webhooks/gmail
|
||||||
|
* Gmail Push Notification Webhook
|
||||||
|
*
|
||||||
|
* This endpoint receives push notifications from Gmail when new emails arrive.
|
||||||
|
* It processes the email and generates an AI-powered reply using the knowledge base.
|
||||||
|
*
|
||||||
|
* Setup:
|
||||||
|
* 1. Register this webhook URL in Google Cloud Console
|
||||||
|
* 2. Set up Gmail watch for each user's mailbox
|
||||||
|
* 3. When emails arrive, Gmail sends a notification here
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Gmail sends a verification token on initial setup
|
||||||
|
const verificationToken = req.headers.get("x-goog-challenge")
|
||||||
|
if (verificationToken) {
|
||||||
|
logger.info("Gmail webhook verification request received")
|
||||||
|
// Return the verification token to confirm webhook ownership
|
||||||
|
return new NextResponse(verificationToken, { status: 200 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the notification payload
|
||||||
|
const body = await req.json().catch(() => ({}))
|
||||||
|
const messageData = body.message?.data
|
||||||
|
|
||||||
|
if (!messageData) {
|
||||||
|
logger.warn("Gmail webhook received without message data")
|
||||||
|
return NextResponse.json({ message: "No message data" }, { status: 200 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode base64url encoded message data
|
||||||
|
const decodedData = Buffer.from(messageData, "base64")
|
||||||
|
.toString("utf-8")
|
||||||
|
|
||||||
|
const notification = JSON.parse(decodedData)
|
||||||
|
const emailAddress = notification.emailAddress
|
||||||
|
const historyId = notification.historyId
|
||||||
|
|
||||||
|
logger.info("Gmail webhook notification received", {
|
||||||
|
emailAddress,
|
||||||
|
historyId,
|
||||||
|
notification
|
||||||
|
})
|
||||||
|
|
||||||
|
// Find the user by Gmail email address
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const integration = await (prisma as any).gmailIntegration.findFirst({
|
||||||
|
where: {
|
||||||
|
email: emailAddress
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
include: {
|
||||||
|
stores: {
|
||||||
|
where: { isActive: true },
|
||||||
|
take: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!integration) {
|
||||||
|
logger.warn("Gmail integration not found for email", { emailAddress })
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Integration not found" },
|
||||||
|
{ status: 200 } // Return 200 to prevent retries
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = integration.userId
|
||||||
|
const storeId = integration.user.stores[0]?.id || null
|
||||||
|
|
||||||
|
// Process the incoming email
|
||||||
|
// This will fetch the email, analyze it, and send an AI-powered or fallback reply
|
||||||
|
const result = await processIncomingEmail(userId, storeId, historyId)
|
||||||
|
|
||||||
|
logger.info("Gmail webhook processing result", {
|
||||||
|
userId,
|
||||||
|
storeId,
|
||||||
|
historyId,
|
||||||
|
success: result.success,
|
||||||
|
error: result.error,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: result.success,
|
||||||
|
error: result.error,
|
||||||
|
message: result.success
|
||||||
|
? "Email processed successfully"
|
||||||
|
: "Email processing completed with errors (see logs)"
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Gmail webhook error", error)
|
||||||
|
// Return 200 to prevent Gmail from retrying
|
||||||
|
// Log errors internally for debugging
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Webhook processed with errors" },
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/webhooks/gmail
|
||||||
|
* Health check endpoint
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({
|
||||||
|
status: "ok",
|
||||||
|
message: "Gmail webhook endpoint is active"
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
Search,
|
Search,
|
||||||
@ -8,33 +8,186 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Edit,
|
Edit,
|
||||||
Trash2,
|
Trash2,
|
||||||
Copy,
|
|
||||||
Eye
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
import { renderTemplate } from "@/lib/templates/simpleRenderer"
|
||||||
|
|
||||||
interface EmailTemplate {
|
type TemplateIntent = "general" | "shipping_delay" | "refund_request" | "question"
|
||||||
|
type TemplateMood = "neutral" | "angry" | "confused" | "happy"
|
||||||
|
|
||||||
|
interface Template {
|
||||||
id: string
|
id: string
|
||||||
|
storeId: string
|
||||||
name: string
|
name: string
|
||||||
subject: string
|
intent: TemplateIntent | string
|
||||||
category: "support" | "order" | "marketing" | "general"
|
mood: TemplateMood | string
|
||||||
|
description: string | null
|
||||||
|
body: string
|
||||||
|
variables?: unknown
|
||||||
|
isHtml: boolean
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INTENT_OPTIONS: { value: TemplateIntent; label: string }[] = [
|
||||||
|
{ value: "general", label: "General" },
|
||||||
|
{ value: "shipping_delay", label: "Shipping delay" },
|
||||||
|
{ value: "refund_request", label: "Refund request" },
|
||||||
|
{ value: "question", label: "General question" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const MOOD_OPTIONS: { value: TemplateMood; label: string }[] = [
|
||||||
|
{ value: "neutral", label: "Neutral" },
|
||||||
|
{ value: "angry", label: "Frustrated / Angry" },
|
||||||
|
{ value: "confused", label: "Confused" },
|
||||||
|
{ value: "happy", label: "Happy" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const exampleContext = {
|
||||||
|
customer_name: "Alex",
|
||||||
|
customer_email: "alex@example.com",
|
||||||
|
store_name: "JUNOVendor",
|
||||||
|
store_email: "support@junovendor.com",
|
||||||
|
subject: "Where is my order?",
|
||||||
|
ticket_summary: "Customer is asking about a shipping delay on order #1234.",
|
||||||
|
}
|
||||||
|
|
||||||
export default function EmailTemplatesPage() {
|
export default function EmailTemplatesPage() {
|
||||||
const [searchTerm, setSearchTerm] = useState("")
|
const [searchTerm, setSearchTerm] = useState("")
|
||||||
const [templates, setTemplates] = useState<EmailTemplate[]>([])
|
const [templates, setTemplates] = useState<Template[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const getCategoryStyles = (category: string) => {
|
const [editing, setEditing] = useState<Template | null>(null)
|
||||||
switch (category) {
|
const [saving, setSaving] = useState(false)
|
||||||
case "support": return "bg-blue-500/20 text-blue-400"
|
|
||||||
case "order": return "bg-emerald-500/20 text-emerald-400"
|
const loadTemplates = async () => {
|
||||||
case "marketing": return "bg-purple-500/20 text-purple-400"
|
try {
|
||||||
case "general": return "bg-slate-500/20 text-slate-400"
|
setLoading(true)
|
||||||
default: return "bg-white/10 text-slate-400"
|
setError(null)
|
||||||
|
const res = await fetch("/api/juno-engine/templates", { cache: "no-store" })
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Failed to load templates")
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { templates: Template[] }
|
||||||
|
setTemplates(data.templates || [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setError("Could not load templates. Please try again.")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadTemplates()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filteredTemplates = useMemo(() => {
|
||||||
|
if (!searchTerm.trim()) return templates
|
||||||
|
const q = searchTerm.toLowerCase()
|
||||||
|
return templates.filter((tpl) =>
|
||||||
|
[tpl.name, tpl.intent, tpl.mood, tpl.description || ""]
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(q),
|
||||||
|
)
|
||||||
|
}, [templates, searchTerm])
|
||||||
|
|
||||||
|
const startNewTemplate = () => {
|
||||||
|
setEditing({
|
||||||
|
id: "",
|
||||||
|
storeId: "",
|
||||||
|
name: "",
|
||||||
|
intent: "general",
|
||||||
|
mood: "neutral",
|
||||||
|
description: "",
|
||||||
|
body:
|
||||||
|
"Hi {{customer_name}},\n\nThank you for reaching out. {{ticket_summary}}\n\nBest regards,\n{{store_name}} Support Team",
|
||||||
|
variables: undefined,
|
||||||
|
isHtml: false,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const startEditTemplate = (template: Template) => {
|
||||||
|
setEditing(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (template: Template) => {
|
||||||
|
if (!window.confirm(`Delete template "${template.name}"?`)) return
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/juno-engine/templates/${encodeURIComponent(template.id)}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
)
|
||||||
|
if (!res.ok) throw new Error("Failed to delete template")
|
||||||
|
setTemplates((prev) => prev.filter((t) => t.id !== template.id))
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
alert("Failed to delete template. Please try again.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!editing) return
|
||||||
|
if (!editing.name.trim()) {
|
||||||
|
alert("Template name is required.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!editing.body.trim()) {
|
||||||
|
alert("Template body is required.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true)
|
||||||
|
const payload = {
|
||||||
|
name: editing.name.trim(),
|
||||||
|
intent: editing.intent,
|
||||||
|
mood: editing.mood,
|
||||||
|
description: editing.description || null,
|
||||||
|
body: editing.body,
|
||||||
|
variables: editing.variables,
|
||||||
|
isHtml: editing.isHtml,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editing.id) {
|
||||||
|
const res = await fetch("/api/juno-engine/templates", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error("Failed to create template")
|
||||||
|
const data = (await res.json()) as { template: Template }
|
||||||
|
setTemplates((prev) => [data.template, ...prev])
|
||||||
|
setEditing(null)
|
||||||
|
} else {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/juno-engine/templates/${encodeURIComponent(editing.id)}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!res.ok) throw new Error("Failed to update template")
|
||||||
|
const data = (await res.json()) as { template: Template }
|
||||||
|
setTemplates((prev) =>
|
||||||
|
prev.map((t) => (t.id === data.template.id ? data.template : t)),
|
||||||
|
)
|
||||||
|
setEditing(null)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
alert("Failed to save template. Please try again.")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPreview = editing
|
||||||
|
? renderTemplate(editing.body, exampleContext)
|
||||||
|
: ""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col space-y-4 md:space-y-6">
|
<div className="h-full flex flex-col space-y-4 md:space-y-6">
|
||||||
<div className="bg-white/5 backdrop-blur-xl rounded-xl md:rounded-2xl border border-white/10 overflow-hidden flex flex-col flex-1 min-h-0">
|
<div className="bg-white/5 backdrop-blur-xl rounded-xl md:rounded-2xl border border-white/10 overflow-hidden flex flex-col flex-1 min-h-0">
|
||||||
@ -43,13 +196,16 @@ export default function EmailTemplatesPage() {
|
|||||||
<Search className="absolute left-3 md:left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
<Search className="absolute left-3 md:left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search email templates..."
|
placeholder="Search templates by name, intent, or mood..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="w-full h-10 md:h-11 pl-10 md:pl-11 pr-4 bg-white/5 border border-white/10 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 text-white placeholder:text-slate-500 text-sm font-medium transition-all"
|
className="w-full h-10 md:h-11 pl-10 md:pl-11 pr-4 bg-white/5 border border-white/10 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 text-white placeholder:text-slate-500 text-sm font-medium transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button className="h-10 md:h-11 px-4 bg-indigo-500 text-white rounded-xl font-bold flex items-center gap-2 hover:bg-indigo-600 border border-indigo-400/20 transition-all text-sm shrink-0">
|
<button
|
||||||
|
onClick={startNewTemplate}
|
||||||
|
className="h-10 md:h-11 px-4 bg-indigo-500 text-white rounded-xl font-bold flex items-center gap-2 hover:bg-indigo-600 border border-indigo-400/20 transition-all text-sm shrink-0"
|
||||||
|
>
|
||||||
<Plus className="w-4 h-4" /> New Template
|
<Plus className="w-4 h-4" /> New Template
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -61,57 +217,279 @@ export default function EmailTemplatesPage() {
|
|||||||
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
|
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
|
||||||
<p className="font-medium animate-pulse">Loading templates...</p>
|
<p className="font-medium animate-pulse">Loading templates...</p>
|
||||||
</div>
|
</div>
|
||||||
) : templates.length === 0 ? (
|
) : error ? (
|
||||||
|
<div className="h-64 flex flex-col items-center justify-center text-red-400 gap-3 text-center px-4">
|
||||||
|
<p className="font-medium">{error}</p>
|
||||||
|
</div>
|
||||||
|
) : filteredTemplates.length === 0 ? (
|
||||||
<div className="h-64 flex flex-col items-center justify-center text-slate-400 gap-3">
|
<div className="h-64 flex flex-col items-center justify-center text-slate-400 gap-3">
|
||||||
<FileText className="w-10 h-10 opacity-20" />
|
<FileText className="w-10 h-10 opacity-20" />
|
||||||
<p className="font-medium">No email templates found.</p>
|
<p className="font-medium">No email templates found.</p>
|
||||||
<p className="text-xs text-slate-500">Create your first template to get started.</p>
|
<p className="text-xs text-slate-500">
|
||||||
|
Create your first template to get started.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6 p-4 md:p-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6 p-4 md:p-6">
|
||||||
{templates.map((template) => (
|
{filteredTemplates.map((template) => (
|
||||||
<div
|
<button
|
||||||
key={template.id}
|
key={template.id}
|
||||||
className="bg-white/5 backdrop-blur-xl p-4 md:p-6 rounded-xl md:rounded-2xl border border-white/10 hover:bg-white/[0.07] hover:border-indigo-500/30 transition-all group"
|
type="button"
|
||||||
|
onClick={() => startEditTemplate(template)}
|
||||||
|
className="bg-white/5 backdrop-blur-xl p-4 md:p-6 rounded-xl md:rounded-2xl border border-white/10 hover:bg-white/[0.07] hover:border-indigo-500/30 transition-all group text-left"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-bold text-base md:text-lg text-white truncate group-hover:text-indigo-400 transition-colors">
|
<h3 className="font-bold text-base md:text-lg text-white truncate group-hover:text-indigo-400 transition-colors">
|
||||||
{template.name}
|
{template.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-slate-400 mt-1 truncate">{template.subject}</p>
|
<p className="text-xs text-slate-400 mt-1 truncate">
|
||||||
|
Intent: {template.intent} • Mood:{" "}
|
||||||
|
{template.mood}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={`px-2 py-1 rounded-full text-[10px] font-black uppercase tracking-wider shrink-0 ${getCategoryStyles(template.category)}`}>
|
|
||||||
{template.category}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-4 border-t border-white/10">
|
<p className="text-xs text-slate-300 line-clamp-3">
|
||||||
|
{template.description ||
|
||||||
|
template.body.split("\n").slice(0, 3).join(" ")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t border-white/10 mt-4">
|
||||||
<span className="text-[10px] text-slate-500 font-medium">
|
<span className="text-[10px] text-slate-500 font-medium">
|
||||||
Updated {new Date(template.updatedAt).toLocaleDateString()}
|
Updated{" "}
|
||||||
|
{new Date(
|
||||||
|
template.updatedAt,
|
||||||
|
).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button className="p-1.5 hover:bg-white/10 rounded-lg text-slate-400 hover:text-indigo-400 transition-all" title="Preview">
|
<span className="text-[10px] text-slate-400 uppercase tracking-wide">
|
||||||
<Eye className="w-4 h-4" />
|
Edit
|
||||||
</button>
|
</span>
|
||||||
<button className="p-1.5 hover:bg-white/10 rounded-lg text-slate-400 hover:text-indigo-400 transition-all" title="Edit">
|
<button
|
||||||
<Edit className="w-4 h-4" />
|
type="button"
|
||||||
</button>
|
onClick={(e) => {
|
||||||
<button className="p-1.5 hover:bg-white/10 rounded-lg text-slate-400 hover:text-indigo-400 transition-all" title="Duplicate">
|
e.stopPropagation()
|
||||||
<Copy className="w-4 h-4" />
|
handleDelete(template)
|
||||||
</button>
|
}}
|
||||||
<button className="p-1.5 hover:bg-white/10 rounded-lg text-slate-400 hover:text-red-400 transition-all" title="Delete">
|
className="p-1.5 hover:bg-white/10 rounded-lg text-slate-400 hover:text-red-400 transition-all"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<div className="fixed inset-0 z-40 flex items-center justify-end bg-black/40 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-2xl h-full md:h-auto md:max-h-[90vh] bg-slate-950/95 border-l border-white/10 md:rounded-l-2xl md:mr-4 md:mb-4 shadow-2xl flex flex-col">
|
||||||
|
<div className="px-5 py-4 border-b border-white/10 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-white">
|
||||||
|
{editing.id ? "Edit template" : "New template"}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
Use {"{{variable}}"} placeholders to personalize replies.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing(null)}
|
||||||
|
className="text-slate-400 hover:text-white text-sm"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editing.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditing((prev) =>
|
||||||
|
prev
|
||||||
|
? { ...prev, name: e.target.value }
|
||||||
|
: prev,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full h-9 px-3 rounded-lg bg-slate-900/60 border border-white/10 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||||
|
Intent
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={editing.intent}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditing((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
intent: e.target.value,
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full h-9 px-3 rounded-lg bg-slate-900/60 border border-white/10 text-xs text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||||
|
>
|
||||||
|
{INTENT_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||||
|
Mood
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={editing.mood}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditing((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
mood: e.target.value,
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full h-9 px-3 rounded-lg bg-slate-900/60 border border-white/10 text-xs text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||||
|
>
|
||||||
|
{MOOD_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||||
|
Description (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editing.description || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditing((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
description: e.target.value,
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full h-9 px-3 rounded-lg bg-slate-900/60 border border-white/10 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||||
|
placeholder="Short summary for your team"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-xs text-slate-400">
|
||||||
|
<p className="font-semibold text-slate-200">
|
||||||
|
Available variables
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
<li>
|
||||||
|
<code className="px-1.5 py-0.5 bg-slate-900/80 rounded border border-white/10 text-[11px]">
|
||||||
|
{"{{customer_name}}"}
|
||||||
|
</code>{" "}
|
||||||
|
– Customer’s name
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code className="px-1.5 py-0.5 bg-slate-900/80 rounded border border-white/10 text-[11px]">
|
||||||
|
{"{{customer_email}}"}
|
||||||
|
</code>{" "}
|
||||||
|
– Customer’s email
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code className="px-1.5 py-0.5 bg-slate-900/80 rounded border border-white/10 text-[11px]">
|
||||||
|
{"{{store_name}}"}
|
||||||
|
</code>{" "}
|
||||||
|
– Store name
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code className="px-1.5 py-0.5 bg-slate-900/80 rounded border border-white/10 text-[11px]">
|
||||||
|
{"{{ticket_summary}}"}
|
||||||
|
</code>{" "}
|
||||||
|
– Short summary of the customer’s email
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1">
|
||||||
|
Template body
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={editing.body}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditing((prev) =>
|
||||||
|
prev ? { ...prev, body: e.target.value } : prev,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
rows={8}
|
||||||
|
className="w-full rounded-lg bg-slate-900/60 border border-white/10 text-xs text-slate-100 font-mono p-3 focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-slate-300 mb-1">
|
||||||
|
Preview with example data
|
||||||
|
</p>
|
||||||
|
<div className="rounded-lg border border-white/10 bg-slate-900/80 p-4 text-xs text-slate-100 whitespace-pre-wrap">
|
||||||
|
{currentPreview || "Preview will appear here."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 py-3 border-t border-white/10 flex items-center justify-between">
|
||||||
|
<p className="text-[11px] text-slate-500">
|
||||||
|
Templates use simple {"{{variable}}"} placeholders only.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing(null)}
|
||||||
|
className="h-8 px-3 rounded-lg border border-white/10 text-xs text-slate-300 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
className="h-8 px-3 rounded-lg bg-indigo-500 text-xs font-semibold text-white hover:bg-indigo-600 border border-indigo-400/30 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save template"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,8 +7,8 @@ export default function JunoEnginePage() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Redirect to tickets page by default
|
// Redirect to knowledge base page by default
|
||||||
router.replace("/dashboard/juno-engine/tickets")
|
router.replace("/dashboard/juno-engine/knowledge-base")
|
||||||
}, [router])
|
}, [router])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
|
|||||||
@ -33,6 +33,7 @@ export default function TicketDetailPage() {
|
|||||||
const [ticket, setTicket] = useState<TicketData | null>(null)
|
const [ticket, setTicket] = useState<TicketData | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [expandedMessages, setExpandedMessages] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadTicket = async () => {
|
const loadTicket = async () => {
|
||||||
@ -92,10 +93,10 @@ export default function TicketDetailPage() {
|
|||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">
|
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">
|
||||||
Ticket Conversation
|
{ticket?.subject || "Ticket"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm md:text-base text-slate-400 mt-1">
|
<p className="text-sm md:text-base text-slate-400 mt-1">
|
||||||
Full automated email history for this customer.
|
{ticket?.customerEmail || "Full automated email history for this customer."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -111,90 +112,116 @@ export default function TicketDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-4xl mx-auto px-2 md:px-0 space-y-4">
|
||||||
<div className="bg-white/5 rounded-2xl border border-white/10 p-6 md:p-7 space-y-5">
|
{/* Meta badges */}
|
||||||
{/* Ticket header */}
|
<div className="flex flex-wrap items-center justify-between gap-3 text-xs">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="flex items-start gap-3 flex-1 min-w-0">
|
<span
|
||||||
<div className="w-11 h-11 rounded-xl bg-indigo-500/20 border border-indigo-400/20 flex items-center justify-center text-indigo-400 shrink-0">
|
className={`px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-wider ${getStatusStyles(
|
||||||
<Ticket className="w-5 h-5" />
|
ticket.status as TicketStatus
|
||||||
</div>
|
)}`}
|
||||||
<div className="flex-1 min-w-0">
|
>
|
||||||
<h2 className="text-lg md:text-xl font-extrabold text-white truncate">
|
{ticket.status}
|
||||||
{ticket.subject}
|
</span>
|
||||||
</h2>
|
<span className="px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-wider bg-white/5 text-slate-300 border border-white/10">
|
||||||
<p className="text-xs text-slate-400 mt-1 truncate">
|
{ticket.messagesCount} messages
|
||||||
{ticket.customerEmail}
|
</span>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button className="p-2 hover:bg-white/10 rounded-lg text-slate-400 hover:text-indigo-400 transition-all shrink-0">
|
|
||||||
<MoreHorizontal className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-500">
|
||||||
{/* Summary */}
|
Updated{" "}
|
||||||
<div className="text-sm text-slate-200">
|
{new Date(ticket.updatedAt).toLocaleString(undefined, {
|
||||||
<p className="line-clamp-3">{ticket.lastMessageSnippet}</p>
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Meta badges */}
|
{/* Conversation timeline - chat style */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 text-xs">
|
<div className="pt-2 space-y-4">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
{ticket.messages.map((message) => {
|
||||||
<span
|
const isIncoming =
|
||||||
className={`px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-wider ${getStatusStyles(
|
message.trigger === "GMAIL_INCOMING" || message.status === "RECEIVED"
|
||||||
ticket.status as TicketStatus
|
|
||||||
)}`}
|
|
||||||
>
|
|
||||||
{ticket.status}
|
|
||||||
</span>
|
|
||||||
<span className="px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-wider bg-white/5 text-slate-300 border border-white/10">
|
|
||||||
{ticket.messagesCount} messages
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] text-slate-500">
|
|
||||||
Updated{" "}
|
|
||||||
{new Date(ticket.updatedAt).toLocaleString(undefined, {
|
|
||||||
dateStyle: "medium",
|
|
||||||
timeStyle: "short",
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Conversation timeline */}
|
const fullText = message.bodyPreview || ""
|
||||||
<div className="pt-4 border-t border-white/10 space-y-3">
|
const lines = fullText.split(/\r?\n/)
|
||||||
{ticket.messages.map((message) => (
|
const isExpanded = !!expandedMessages[message.id]
|
||||||
<div
|
const hasMore = lines.length > 2
|
||||||
key={message.id}
|
const displayText =
|
||||||
className="rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-[11px] text-slate-200"
|
isExpanded || !hasMore ? fullText : lines.slice(0, 2).join("\n")
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div
|
||||||
<span className="font-semibold text-slate-100 uppercase tracking-wide">
|
key={message.id}
|
||||||
{message.trigger || "AUTOMATED"}
|
className={`flex w-full ${isIncoming ? "justify-start" : "justify-end"}`}
|
||||||
</span>
|
>
|
||||||
<span className="px-2 py-0.5 rounded-full bg-slate-900/40 border border-white/10 text-[10px] text-slate-300">
|
<div
|
||||||
{message.status}
|
className={`w-full sm:max-w-[88%] rounded-2xl px-4 py-3 text-xs shadow-sm flex flex-col gap-1
|
||||||
</span>
|
${
|
||||||
|
isIncoming
|
||||||
|
? "bg-slate-900/70 border border-white/10 rounded-bl-sm"
|
||||||
|
: "bg-indigo-500/90 border border-indigo-300/60 text-white rounded-br-sm"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wide opacity-80">
|
||||||
|
{isIncoming ? "Customer" : "JUNOHUB"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 rounded-full text-[9px] font-semibold tracking-wide
|
||||||
|
${
|
||||||
|
isIncoming
|
||||||
|
? "bg-slate-800/80 text-slate-200 border border-white/10"
|
||||||
|
: "bg-indigo-600/90 text-indigo-50 border border-indigo-300/70"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{message.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-slate-400 whitespace-nowrap">
|
||||||
|
{new Date(message.sentAt).toLocaleTimeString(undefined, {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fullText && (
|
||||||
|
<p
|
||||||
|
className={`mt-0.5 leading-snug whitespace-pre-wrap break-words ${
|
||||||
|
isIncoming ? "text-slate-200" : "text-indigo-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{displayText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedMessages((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[message.id]: !isExpanded,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className={`self-end mt-1 text-[10px] font-medium underline underline-offset-2 ${
|
||||||
|
isIncoming ? "text-slate-300" : "text-indigo-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isExpanded ? "Show less" : "Show more"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-slate-500">
|
|
||||||
{new Date(message.sentAt).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{message.bodyPreview && (
|
)
|
||||||
<p className="text-slate-300 leading-snug">
|
})}
|
||||||
{message.bodyPreview}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{ticket.messages.length === 0 && (
|
{ticket.messages.length === 0 && (
|
||||||
<p className="text-[11px] text-slate-500">
|
<p className="text-[11px] text-slate-500 text-center">
|
||||||
No messages logged yet for this ticket.
|
No messages logged yet for this ticket.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import {
|
|||||||
Ticket,
|
Ticket,
|
||||||
Search,
|
Search,
|
||||||
Filter,
|
Filter,
|
||||||
Plus,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
MoreHorizontal
|
MoreHorizontal
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
@ -99,9 +98,6 @@ export default function TicketsPage() {
|
|||||||
<button className="h-10 md:h-11 px-4 bg-white/10 border border-white/10 rounded-xl font-bold text-slate-300 hover:bg-white/15 transition-all flex items-center justify-center gap-2 text-sm shrink-0">
|
<button className="h-10 md:h-11 px-4 bg-white/10 border border-white/10 rounded-xl font-bold text-slate-300 hover:bg-white/15 transition-all flex items-center justify-center gap-2 text-sm shrink-0">
|
||||||
<Filter className="w-4 h-4" /> Filters
|
<Filter className="w-4 h-4" /> Filters
|
||||||
</button>
|
</button>
|
||||||
<button className="h-10 md:h-11 px-4 bg-indigo-500 text-white rounded-xl font-bold flex items-center gap-2 hover:bg-indigo-600 border border-indigo-400/20 transition-all text-sm shrink-0">
|
|
||||||
<Plus className="w-4 h-4" /> New Ticket
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import StoreSwitcher from "@/components/StoreSwitcher"
|
|||||||
import UserAccountMenu from "@/components/UserAccountMenu"
|
import UserAccountMenu from "@/components/UserAccountMenu"
|
||||||
import NotificationBell from "@/components/NotificationBell"
|
import NotificationBell from "@/components/NotificationBell"
|
||||||
import PageTransition from "@/components/PageTransition"
|
import PageTransition from "@/components/PageTransition"
|
||||||
|
import ThemeToggle from "@/components/ThemeToggle"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
@ -54,10 +55,15 @@ export default async function DashboardLayout({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 md:gap-4 shrink-0">
|
<div className="flex items-center gap-2 md:gap-4 shrink-0">
|
||||||
|
<ThemeToggle />
|
||||||
<NotificationBell />
|
<NotificationBell />
|
||||||
<div className="hidden sm:flex flex-col text-right">
|
<div className="hidden sm:flex flex-col text-right">
|
||||||
<span className="text-xs font-bold text-white truncate max-w-[120px]">{session.user?.name || "Vendor User"}</span>
|
<span className="text-xs font-bold text-white truncate max-w-[120px]">
|
||||||
<span className="text-[10px] text-slate-400 truncate max-w-[120px]">{session.user?.email}</span>
|
{session.user?.name || "Vendor User"}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-400 truncate max-w-[120px]">
|
||||||
|
{session.user?.email}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<UserAccountMenu />
|
<UserAccountMenu />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,18 +6,33 @@
|
|||||||
--color-slate-custom: #f8fafc;
|
--color-slate-custom: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Default (current) theme */
|
||||||
:root {
|
:root {
|
||||||
--background: #f8fafc;
|
--background: #0a0e1a;
|
||||||
--foreground: #0f172a;
|
--foreground: #f9fafb;
|
||||||
--primary: var(--color-indigo-accent);
|
--primary: var(--color-indigo-accent);
|
||||||
/* Glassmorphic dark mode */
|
/* Glassmorphic dark mode */
|
||||||
--glass-bg: rgba(15, 23, 42, 0.6);
|
--glass-bg: rgba(15, 23, 42, 0.75);
|
||||||
--glass-border: rgba(255, 255, 255, 0.08);
|
--glass-border: rgba(148, 163, 184, 0.35);
|
||||||
--glass-card: rgba(255, 255, 255, 0.05);
|
--glass-card: rgba(15, 23, 42, 0.9);
|
||||||
--dashboard-bg: #0a0e1a;
|
--dashboard-bg: #0a0e1a;
|
||||||
--dashboard-mesh: radial-gradient(ellipse 80% 50% at 50% -20%, rgba(79, 70, 229, 0.15), transparent),
|
--dashboard-mesh: radial-gradient(ellipse 80% 50% at 50% -20%, rgba(79, 70, 229, 0.25), transparent),
|
||||||
radial-gradient(ellipse 60% 40% at 100% 50%, rgba(30, 58, 138, 0.08), transparent),
|
radial-gradient(ellipse 60% 40% at 100% 50%, rgba(30, 64, 175, 0.18), transparent),
|
||||||
radial-gradient(ellipse 50% 30% at 0% 80%, rgba(16, 185, 129, 0.06), transparent);
|
radial-gradient(ellipse 50% 30% at 0% 80%, rgba(16, 185, 129, 0.14), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light mode overrides */
|
||||||
|
html[data-theme="light"] {
|
||||||
|
--background: #f8fafc;
|
||||||
|
--foreground: #020617;
|
||||||
|
--primary: var(--color-indigo-accent);
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.9);
|
||||||
|
--glass-border: rgba(148, 163, 184, 0.35);
|
||||||
|
--glass-card: rgba(255, 255, 255, 0.95);
|
||||||
|
--dashboard-bg: #f9fafb;
|
||||||
|
--dashboard-mesh: radial-gradient(ellipse 80% 50% at 50% -20%, rgba(191, 219, 254, 0.6), transparent),
|
||||||
|
radial-gradient(ellipse 60% 40% at 100% 50%, rgba(221, 214, 254, 0.5), transparent),
|
||||||
|
radial-gradient(ellipse 50% 30% at 0% 80%, rgba(187, 247, 208, 0.5), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@ -35,6 +50,12 @@ body {
|
|||||||
background-image: var(--dashboard-mesh);
|
background-image: var(--dashboard-mesh);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Light-mode specific layout tweaks */
|
||||||
|
html[data-theme="light"] .dashboard-glass-dark {
|
||||||
|
background-color: var(--dashboard-bg);
|
||||||
|
background-image: var(--dashboard-mesh);
|
||||||
|
}
|
||||||
|
|
||||||
/* Auth pages (login, signup) - same dark glass background */
|
/* Auth pages (login, signup) - same dark glass background */
|
||||||
.auth-glass-dark {
|
.auth-glass-dark {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@ -57,6 +78,48 @@ body {
|
|||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ----------------------------- */
|
||||||
|
/* Light mode utility overrides */
|
||||||
|
/* ----------------------------- */
|
||||||
|
|
||||||
|
/* Text colors: make typography readable on light backgrounds */
|
||||||
|
html[data-theme="light"] .text-white {
|
||||||
|
color: #020617 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .text-slate-400 {
|
||||||
|
color: #6b7280 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .text-slate-300 {
|
||||||
|
color: #4b5563 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .text-slate-200 {
|
||||||
|
color: #374151 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar & header backgrounds in light mode */
|
||||||
|
html[data-theme="light"] .bg-slate-900\/70 {
|
||||||
|
background-color: rgba(255, 255, 255, 0.98) !important;
|
||||||
|
border-color: rgba(148, 163, 184, 0.6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .bg-slate-900\/50 {
|
||||||
|
background-color: rgba(255, 255, 255, 0.9) !important;
|
||||||
|
border-color: rgba(148, 163, 184, 0.4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small chips / badges that were pure white text */
|
||||||
|
html[data-theme="light"] .bg-white\/10 {
|
||||||
|
background-color: rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .bg-white\/5 {
|
||||||
|
background-color: rgba(15, 23, 42, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.glow-emerald {
|
.glow-emerald {
|
||||||
box-shadow: 0 0 20px rgba(16, 185, 129, 0.2);
|
box-shadow: 0 0 20px rgba(16, 185, 129, 0.2);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
|
|||||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
adapter: PrismaAdapter(prisma),
|
adapter: PrismaAdapter(prisma),
|
||||||
session: { strategy: "jwt" },
|
session: { strategy: "jwt" },
|
||||||
|
trustHost: true, // Trust all hosts (needed for ngrok/proxies)
|
||||||
...authConfig,
|
...authConfig,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async jwt({ token, user }) {
|
async jwt({ token, user }) {
|
||||||
@ -22,7 +23,9 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
if (token && session.user) {
|
if (token && session.user) {
|
||||||
session.user.id = token.id as string
|
session.user.id = token.id as string
|
||||||
session.user.role = token.role as import("@prisma/client").UserRole
|
if (token.role) {
|
||||||
|
session.user.role = token.role as "VENDOR" | "SUPPLIER" | "ADMIN"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return session
|
return session
|
||||||
},
|
},
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState, useRef } from "react"
|
import { useEffect, useState, useRef } from "react"
|
||||||
import { Bell, Loader2, CheckCircle2, Clock, XCircle, Mail, Package, ShoppingCart, X } from "lucide-react"
|
import { Bell, Loader2, CheckCircle2, Clock, XCircle, Mail, Package, ShoppingCart, X } from "lucide-react"
|
||||||
import { formatDistanceToNow } from "date-fns"
|
import { formatDistanceToNow } from "date-fns"
|
||||||
|
import { useTheme } from "@/components/ThemeProvider"
|
||||||
|
|
||||||
interface Notification {
|
interface Notification {
|
||||||
id: string
|
id: string
|
||||||
@ -21,6 +22,8 @@ export default function NotificationBell() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const isLight = theme === "light"
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchNotifications()
|
fetchNotifications()
|
||||||
@ -122,24 +125,50 @@ export default function NotificationBell() {
|
|||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className="relative p-2 rounded-lg hover:bg-white/10 transition-colors"
|
className={`relative p-2 rounded-lg transition-colors ${
|
||||||
|
isLight ? "hover:bg-slate-100" : "hover:bg-white/10"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Bell className="w-5 h-5 text-slate-400" />
|
<Bell
|
||||||
|
className={`w-5 h-5 ${
|
||||||
|
isLight ? "text-slate-600" : "text-slate-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<span className="absolute top-1 right-1 w-4 h-4 bg-red-500 rounded-full flex items-center justify-center text-[10px] font-bold text-white">
|
<span className="absolute top-1 right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white">
|
||||||
{unreadCount > 9 ? "9+" : unreadCount}
|
{unreadCount > 9 ? "9+" : unreadCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="absolute right-0 top-full mt-2 w-80 sm:w-96 bg-slate-800 border border-white/10 rounded-xl shadow-xl z-50 max-h-[600px] flex flex-col">
|
<div
|
||||||
<div className="p-4 border-b border-white/10 flex items-center justify-between">
|
className={`absolute right-0 top-full z-50 mt-2 flex max-h-[600px] w-80 flex-col rounded-xl border shadow-xl sm:w-96 ${
|
||||||
<h3 className="font-bold text-white">Notifications</h3>
|
isLight
|
||||||
|
? "bg-white border-slate-200"
|
||||||
|
: "bg-slate-800 border-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-between border-b p-4 ${
|
||||||
|
isLight ? "border-slate-200" : "border-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
className={`font-bold ${
|
||||||
|
isLight ? "text-slate-900" : "text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Notifications
|
||||||
|
</h3>
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={markAllAsRead}
|
onClick={markAllAsRead}
|
||||||
className="text-xs text-indigo-400 hover:text-indigo-300 font-medium"
|
className={`text-xs font-medium ${
|
||||||
|
isLight
|
||||||
|
? "text-indigo-600 hover:text-indigo-500"
|
||||||
|
: "text-indigo-400 hover:text-indigo-300"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Mark all as read
|
Mark all as read
|
||||||
</button>
|
</button>
|
||||||
@ -149,20 +178,54 @@ export default function NotificationBell() {
|
|||||||
<div className="overflow-y-auto flex-1">
|
<div className="overflow-y-auto flex-1">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-indigo-400" />
|
<Loader2
|
||||||
|
className={`h-6 w-6 animate-spin ${
|
||||||
|
isLight
|
||||||
|
? "text-indigo-600"
|
||||||
|
: "text-indigo-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : notifications.length === 0 ? (
|
) : notifications.length === 0 ? (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
<Bell className="w-12 h-12 text-slate-400 mx-auto mb-3 opacity-50" />
|
<Bell
|
||||||
<p className="text-sm text-slate-400">No notifications yet</p>
|
className={`mx-auto mb-3 h-12 w-12 opacity-50 ${
|
||||||
|
isLight
|
||||||
|
? "text-slate-400"
|
||||||
|
: "text-slate-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
className={`text-sm ${
|
||||||
|
isLight
|
||||||
|
? "text-slate-500"
|
||||||
|
: "text-slate-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
No notifications yet
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-white/10">
|
<div
|
||||||
|
className={`divide-y ${
|
||||||
|
isLight
|
||||||
|
? "divide-slate-100"
|
||||||
|
: "divide-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{notifications.map((notification) => (
|
{notifications.map((notification) => (
|
||||||
<div
|
<div
|
||||||
key={notification.id}
|
key={notification.id}
|
||||||
className={`p-4 hover:bg-white/5 transition-colors ${
|
className={`p-4 transition-colors ${
|
||||||
!notification.read ? "bg-indigo-500/10" : ""
|
isLight
|
||||||
|
? "hover:bg-slate-50"
|
||||||
|
: "hover:bg-white/5"
|
||||||
|
} ${
|
||||||
|
!notification.read
|
||||||
|
? isLight
|
||||||
|
? "bg-indigo-50"
|
||||||
|
: "bg-indigo-500/10"
|
||||||
|
: ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
@ -172,13 +235,31 @@ export default function NotificationBell() {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h4 className="text-sm font-semibold text-white mb-1">
|
<h4
|
||||||
|
className={`mb-1 text-sm font-semibold ${
|
||||||
|
isLight
|
||||||
|
? "text-slate-900"
|
||||||
|
: "text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{notification.title}
|
{notification.title}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-xs text-slate-300 mb-2">
|
<p
|
||||||
|
className={`mb-2 text-xs ${
|
||||||
|
isLight
|
||||||
|
? "text-slate-600"
|
||||||
|
: "text-slate-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{notification.message}
|
{notification.message}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] text-slate-500">
|
<p
|
||||||
|
className={`text-[10px] ${
|
||||||
|
isLight
|
||||||
|
? "text-slate-400"
|
||||||
|
: "text-slate-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{formatDistanceToNow(new Date(notification.createdAt), {
|
{formatDistanceToNow(new Date(notification.createdAt), {
|
||||||
addSuffix: true
|
addSuffix: true
|
||||||
})}
|
})}
|
||||||
@ -188,18 +269,38 @@ export default function NotificationBell() {
|
|||||||
{!notification.read && (
|
{!notification.read && (
|
||||||
<button
|
<button
|
||||||
onClick={() => markAsRead(notification.id)}
|
onClick={() => markAsRead(notification.id)}
|
||||||
className="p-1 hover:bg-white/10 rounded transition-colors"
|
className={`rounded p-1 transition-colors ${
|
||||||
|
isLight
|
||||||
|
? "hover:bg-slate-100"
|
||||||
|
: "hover:bg-white/10"
|
||||||
|
}`}
|
||||||
title="Mark as read"
|
title="Mark as read"
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="w-3 h-3 text-slate-400" />
|
<CheckCircle2
|
||||||
|
className={`h-3 w-3 ${
|
||||||
|
isLight
|
||||||
|
? "text-slate-400"
|
||||||
|
: "text-slate-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => deleteNotification(notification.id)}
|
onClick={() => deleteNotification(notification.id)}
|
||||||
className="p-1 hover:bg-white/10 rounded transition-colors"
|
className={`rounded p-1 transition-colors ${
|
||||||
|
isLight
|
||||||
|
? "hover:bg-slate-100"
|
||||||
|
: "hover:bg-white/10"
|
||||||
|
}`}
|
||||||
title="Delete"
|
title="Delete"
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3 text-slate-400" />
|
<X
|
||||||
|
className={`h-3 w-3 ${
|
||||||
|
isLight
|
||||||
|
? "text-slate-400"
|
||||||
|
: "text-slate-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react"
|
|||||||
import { motion, AnimatePresence } from "framer-motion"
|
import { motion, AnimatePresence } from "framer-motion"
|
||||||
import { Store, ChevronDown, Check, Settings } from "lucide-react"
|
import { Store, ChevronDown, Check, Settings } from "lucide-react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useTheme } from "@/components/ThemeProvider"
|
||||||
|
|
||||||
interface StoreData {
|
interface StoreData {
|
||||||
id: string
|
id: string
|
||||||
@ -18,6 +19,8 @@ export default function StoreSwitcher() {
|
|||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const isLight = theme === "light"
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchStores()
|
fetchStores()
|
||||||
@ -92,16 +95,38 @@ export default function StoreSwitcher() {
|
|||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className="flex items-center gap-1.5 md:gap-2 px-2 md:px-4 py-2 bg-white/10 border border-white/10 rounded-xl hover:bg-white/15 backdrop-blur-sm transition-all duration-200 ease-out text-left max-w-full"
|
className={`flex max-w-full items-center gap-1.5 rounded-xl border px-2 py-2 text-left backdrop-blur-sm transition-all duration-200 ease-out md:gap-2 md:px-4 ${
|
||||||
|
isLight
|
||||||
|
? "bg-white border-slate-200 hover:bg-slate-50"
|
||||||
|
: "bg-white/10 border-white/10 hover:bg-white/15"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Store className="w-3.5 h-3.5 md:w-4 md:h-4 text-slate-300 shrink-0" />
|
<Store
|
||||||
<div className="flex flex-col items-start min-w-0 flex-1">
|
className={`h-3.5 w-3.5 shrink-0 md:h-4 md:w-4 ${
|
||||||
<span className="hidden md:block text-xs text-slate-400 font-medium">Store</span>
|
isLight ? "text-slate-600" : "text-slate-300"
|
||||||
<span className="text-xs md:text-sm font-bold text-white truncate max-w-[100px] md:max-w-none">
|
}`}
|
||||||
|
/>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col items-start">
|
||||||
|
<span
|
||||||
|
className={`hidden text-xs font-medium md:block ${
|
||||||
|
isLight ? "text-slate-500" : "text-slate-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Store
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`truncate text-xs font-bold md:text-sm md:max-w-none max-w-[100px] ${
|
||||||
|
isLight ? "text-slate-900" : "text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{activeStore?.businessName || "Select Store"}
|
{activeStore?.businessName || "Select Store"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown className={`w-3.5 h-3.5 md:w-4 md:h-4 text-slate-400 transition-transform duration-200 ease-out shrink-0 ${isOpen ? "rotate-180" : ""}`} />
|
<ChevronDown
|
||||||
|
className={`h-3.5 w-3.5 shrink-0 transition-transform duration-200 ease-out md:h-4 md:w-4 ${
|
||||||
|
isLight ? "text-slate-500" : "text-slate-400"
|
||||||
|
} ${isOpen ? "rotate-180" : ""}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@ -120,51 +145,125 @@ export default function StoreSwitcher() {
|
|||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, y: -8, scale: 0.95 }}
|
exit={{ opacity: 0, y: -8, scale: 0.95 }}
|
||||||
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||||
className="absolute left-0 md:left-auto md:right-0 mt-2 w-[calc(100vw-2rem)] md:w-72 max-w-[320px] bg-slate-900/90 border border-white/10 rounded-2xl shadow-2xl z-[110] overflow-hidden backdrop-blur-xl"
|
className={`absolute left-0 z-[110] mt-2 max-w-[320px] overflow-hidden rounded-2xl border shadow-2xl backdrop-blur-xl md:left-auto md:right-0 w-[calc(100vw-2rem)] md:w-72 ${
|
||||||
|
isLight
|
||||||
|
? "bg-white border-slate-200"
|
||||||
|
: "bg-slate-900/90 border-white/10"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="p-3 border-b border-white/10 bg-white/5">
|
<div
|
||||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">Your Stores</p>
|
className={`p-3 border-b ${
|
||||||
</div>
|
isLight ? "bg-slate-50 border-slate-200" : "bg-white/5 border-white/10"
|
||||||
<div className="max-h-80 overflow-y-auto">
|
}`}
|
||||||
{stores.map((store) => (
|
|
||||||
<button
|
|
||||||
key={store.id}
|
|
||||||
onClick={() => {
|
|
||||||
if (!store.isActive) {
|
|
||||||
handleSwitch(store.id)
|
|
||||||
}
|
|
||||||
setIsOpen(false)
|
|
||||||
}}
|
|
||||||
disabled={loading}
|
|
||||||
className={`w-full px-4 py-3 flex items-center justify-between hover:bg-white/10 transition-all ${store.isActive ? "bg-indigo-500/20" : ""}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${store.isActive ? "bg-indigo-500/50 border border-indigo-400/30" : "bg-white/10"}`}>
|
|
||||||
<Store className={`w-4 h-4 ${store.isActive ? "text-indigo-300" : "text-slate-400"}`} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-start">
|
|
||||||
<span className="text-sm font-bold text-white">{store.businessName}</span>
|
|
||||||
<span className="text-xs text-slate-400">{store.shopifyDomain}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{store.isActive && (
|
|
||||||
<Check className="w-4 h-4 text-indigo-400" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="p-3 border-t border-white/10 bg-white/5">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setIsOpen(false)
|
|
||||||
router.push("/dashboard/stores")
|
|
||||||
}}
|
|
||||||
className="w-full h-9 rounded-lg bg-white/10 text-white text-sm font-bold hover:bg-white/15 border border-white/10 transition-all flex items-center justify-center gap-2"
|
|
||||||
>
|
>
|
||||||
<Settings className="w-4 h-4" />
|
<p
|
||||||
Manage Stores
|
className={`text-xs font-bold uppercase tracking-wider ${
|
||||||
</button>
|
isLight ? "text-slate-500" : "text-slate-400"
|
||||||
</div>
|
}`}
|
||||||
|
>
|
||||||
|
Your Stores
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-80 overflow-y-auto">
|
||||||
|
{stores.map((store) => (
|
||||||
|
<button
|
||||||
|
key={store.id}
|
||||||
|
onClick={() => {
|
||||||
|
if (!store.isActive) {
|
||||||
|
handleSwitch(store.id)
|
||||||
|
}
|
||||||
|
setIsOpen(false)
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
className={`flex w-full items-center justify-between px-4 py-3 transition-all ${
|
||||||
|
isLight
|
||||||
|
? "hover:bg-slate-50"
|
||||||
|
: "hover:bg-white/10"
|
||||||
|
} ${
|
||||||
|
store.isActive
|
||||||
|
? isLight
|
||||||
|
? "bg-indigo-50"
|
||||||
|
: "bg-indigo-500/20"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={`flex h-8 w-8 items-center justify-center rounded-lg ${
|
||||||
|
store.isActive
|
||||||
|
? isLight
|
||||||
|
? "bg-indigo-100 border border-indigo-300"
|
||||||
|
: "bg-indigo-500/50 border border-indigo-400/30"
|
||||||
|
: isLight
|
||||||
|
? "bg-slate-100"
|
||||||
|
: "bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Store
|
||||||
|
className={`h-4 w-4 ${
|
||||||
|
store.isActive
|
||||||
|
? isLight
|
||||||
|
? "text-indigo-600"
|
||||||
|
: "text-indigo-300"
|
||||||
|
: isLight
|
||||||
|
? "text-slate-500"
|
||||||
|
: "text-slate-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<span
|
||||||
|
className={`text-sm font-bold ${
|
||||||
|
isLight
|
||||||
|
? "text-slate-900"
|
||||||
|
: "text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{store.businessName}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-xs ${
|
||||||
|
isLight
|
||||||
|
? "text-slate-500"
|
||||||
|
: "text-slate-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{store.shopifyDomain}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{store.isActive && (
|
||||||
|
<Check
|
||||||
|
className={`h-4 w-4 ${
|
||||||
|
isLight
|
||||||
|
? "text-indigo-600"
|
||||||
|
: "text-indigo-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`p-3 border-t ${
|
||||||
|
isLight ? "bg-slate-50 border-slate-200" : "bg-white/5 border-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false)
|
||||||
|
router.push("/dashboard/stores")
|
||||||
|
}}
|
||||||
|
className={`flex h-9 w-full items-center justify-center gap-2 rounded-lg text-sm font-bold transition-all border ${
|
||||||
|
isLight
|
||||||
|
? "bg-indigo-600 text-white border-indigo-500 hover:bg-indigo-500"
|
||||||
|
: "bg-white/10 text-white border-white/10 hover:bg-white/15"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
Manage Stores
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
87
src/components/ThemeProvider.tsx
Normal file
87
src/components/ThemeProvider.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
ReactNode,
|
||||||
|
} from "react"
|
||||||
|
|
||||||
|
type ThemeMode = "default" | "light"
|
||||||
|
|
||||||
|
type ThemeContextValue = {
|
||||||
|
theme: ThemeMode
|
||||||
|
setTheme: (theme: ThemeMode) => void
|
||||||
|
toggleTheme: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined)
|
||||||
|
|
||||||
|
function applyTheme(mode: ThemeMode) {
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
|
||||||
|
const root = document.documentElement
|
||||||
|
|
||||||
|
if (mode === "light") {
|
||||||
|
root.setAttribute("data-theme", "light")
|
||||||
|
} else {
|
||||||
|
root.removeAttribute("data-theme")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [theme, setThemeState] = useState<ThemeMode>("default")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Initialize from localStorage or fallback to default
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem("juno-theme") as
|
||||||
|
| ThemeMode
|
||||||
|
| null
|
||||||
|
if (stored === "light" || stored === "default") {
|
||||||
|
setThemeState(stored)
|
||||||
|
applyTheme(stored)
|
||||||
|
} else {
|
||||||
|
applyTheme("default")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
applyTheme("default")
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setTheme = (mode: ThemeMode) => {
|
||||||
|
setThemeState(mode)
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem("juno-theme", mode)
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
applyTheme(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
setTheme(theme === "default" ? "light" : "default")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider
|
||||||
|
value={{
|
||||||
|
theme,
|
||||||
|
setTheme,
|
||||||
|
toggleTheme,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const ctx = useContext(ThemeContext)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useTheme must be used within a ThemeProvider")
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
54
src/components/ThemeToggle.tsx
Normal file
54
src/components/ThemeToggle.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTheme } from "@/components/ThemeProvider"
|
||||||
|
import { Sun, Moon } from "lucide-react"
|
||||||
|
|
||||||
|
export default function ThemeToggle() {
|
||||||
|
const { theme, setTheme, toggleTheme } = useTheme()
|
||||||
|
|
||||||
|
const isLight = theme === "light"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full border border-white/15 bg-slate-900/40 px-2.5 py-1 text-[11px] font-medium text-slate-200 hover:bg-white/10"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
>
|
||||||
|
{isLight ? (
|
||||||
|
<Sun className="h-3.5 w-3.5 text-amber-300" />
|
||||||
|
) : (
|
||||||
|
<Moon className="h-3.5 w-3.5 text-sky-300" />
|
||||||
|
)}
|
||||||
|
<span>{isLight ? "Light mode" : "Default mode"}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="hidden md:flex rounded-full border border-white/10 bg-slate-900/50 text-[11px] font-semibold text-slate-300 overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTheme("default")}
|
||||||
|
className={`px-2.5 py-1 ${
|
||||||
|
theme === "default"
|
||||||
|
? "bg-white text-slate-900"
|
||||||
|
: "bg-transparent hover:bg-white/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Default
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTheme("light")}
|
||||||
|
className={`px-2.5 py-1 ${
|
||||||
|
theme === "light"
|
||||||
|
? "bg-white text-slate-900"
|
||||||
|
: "bg-transparent hover:bg-white/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Light
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ -2,36 +2,40 @@
|
|||||||
|
|
||||||
import { SessionProvider } from "next-auth/react"
|
import { SessionProvider } from "next-auth/react"
|
||||||
import { Toaster } from "react-hot-toast"
|
import { Toaster } from "react-hot-toast"
|
||||||
|
import { ThemeProvider } from "@/components/ThemeProvider"
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
{children}
|
<ThemeProvider>
|
||||||
<Toaster
|
{children}
|
||||||
position="top-right"
|
<Toaster
|
||||||
toastOptions={{
|
position="top-right"
|
||||||
duration: 4000,
|
toastOptions={{
|
||||||
style: {
|
duration: 4000,
|
||||||
background: "rgba(30, 41, 59, 0.95)",
|
style: {
|
||||||
color: "#fff",
|
background: "rgba(30, 41, 59, 0.95)",
|
||||||
border: "1px solid rgba(255, 255, 255, 0.1)",
|
color: "#fff",
|
||||||
borderRadius: "12px",
|
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||||
backdropFilter: "blur(12px)",
|
borderRadius: "12px",
|
||||||
},
|
backdropFilter: "blur(12px)",
|
||||||
success: {
|
|
||||||
iconTheme: {
|
|
||||||
primary: "#10b981",
|
|
||||||
secondary: "#fff",
|
|
||||||
},
|
},
|
||||||
},
|
success: {
|
||||||
error: {
|
iconTheme: {
|
||||||
iconTheme: {
|
primary: "#10b981",
|
||||||
primary: "#ef4444",
|
secondary: "#fff",
|
||||||
secondary: "#fff",
|
},
|
||||||
},
|
},
|
||||||
},
|
error: {
|
||||||
}}
|
iconTheme: {
|
||||||
/>
|
primary: "#ef4444",
|
||||||
|
secondary: "#fff",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ThemeProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
455
src/lib/gmail/emailProcessor.ts
Normal file
455
src/lib/gmail/emailProcessor.ts
Normal file
@ -0,0 +1,455 @@
|
|||||||
|
import { getGmailAccessToken } from "../gmail"
|
||||||
|
import { logger } from "../logger"
|
||||||
|
import { sendGmailReply } from "./emailService"
|
||||||
|
import { prisma } from "../prisma"
|
||||||
|
import { renderTemplate } from "@/lib/templates/simpleRenderer"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process an incoming email and generate an AI-powered reply
|
||||||
|
*/
|
||||||
|
export async function processIncomingEmail(
|
||||||
|
userId: string,
|
||||||
|
storeId: string | null,
|
||||||
|
historyId?: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const accessToken = await getGmailAccessToken(userId)
|
||||||
|
if (!accessToken) {
|
||||||
|
logger.warn("Cannot process email - Gmail not connected", { userId })
|
||||||
|
return { success: false, error: "Gmail not connected" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no store is associated, skip processing
|
||||||
|
if (!storeId) {
|
||||||
|
logger.info("No active store found for user, skipping email processing", { userId })
|
||||||
|
return { success: false, error: "No active store" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the most recent unread email
|
||||||
|
const unreadEmails = await getUnreadEmails(accessToken, 1)
|
||||||
|
|
||||||
|
if (unreadEmails.length === 0) {
|
||||||
|
logger.info("No unread emails found", { userId })
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = unreadEmails[0]
|
||||||
|
|
||||||
|
// Extract sender email
|
||||||
|
const emailMatch = email.from.match(/<(.+)>/)
|
||||||
|
const senderEmail = emailMatch ? emailMatch[1] : email.from.split(" ").pop() || email.from
|
||||||
|
|
||||||
|
// Only process emails from Gmail addresses (ending with @gmail.com)
|
||||||
|
if (!senderEmail.toLowerCase().endsWith("@gmail.com")) {
|
||||||
|
logger.info("Skipping non-Gmail email", { userId, senderEmail })
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get full email content
|
||||||
|
const fullEmail = await getEmailContent(accessToken, email.id)
|
||||||
|
if (!fullEmail) {
|
||||||
|
logger.error("Failed to fetch email content", { emailId: email.id })
|
||||||
|
return { success: false, error: "Failed to fetch email content" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract email body (plain text or HTML), cleaned of quoted history
|
||||||
|
const emailBody = extractEmailBody(fullEmail)
|
||||||
|
const emailSubject = email.subject || "No Subject"
|
||||||
|
|
||||||
|
logger.info("Processing incoming email", {
|
||||||
|
userId,
|
||||||
|
storeId,
|
||||||
|
from: senderEmail,
|
||||||
|
subject: emailSubject,
|
||||||
|
emailId: email.id
|
||||||
|
})
|
||||||
|
|
||||||
|
// Log the incoming customer email into EmailLog so tickets show both sides
|
||||||
|
try {
|
||||||
|
const incomingPreview = emailBody
|
||||||
|
.replace(/<[^>]+>/g, "")
|
||||||
|
.replace(/\s+\n/g, "\n")
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim()
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await (prisma as any).emailLog.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
storeId,
|
||||||
|
to: senderEmail, // "to" is the customer email we converse with
|
||||||
|
subject: emailSubject,
|
||||||
|
bodyPreview: incomingPreview,
|
||||||
|
isAutomated: false,
|
||||||
|
trigger: "GMAIL_INCOMING",
|
||||||
|
status: "RECEIVED",
|
||||||
|
providerMessageId: email.messageId,
|
||||||
|
errorMessage: null,
|
||||||
|
sentAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (logError) {
|
||||||
|
logger.error("Failed to log incoming email to EmailLog", logError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to load a store-scoped template for this intent/mood combination
|
||||||
|
let finalReply: string | null = null
|
||||||
|
try {
|
||||||
|
const templateRows = await prisma.$queryRaw<
|
||||||
|
{
|
||||||
|
id: string
|
||||||
|
storeId: string
|
||||||
|
name: string
|
||||||
|
intent: string
|
||||||
|
mood: string
|
||||||
|
description: string | null
|
||||||
|
body: string
|
||||||
|
variables: unknown | null
|
||||||
|
isHtml: boolean
|
||||||
|
updatedAt: Date
|
||||||
|
}[]
|
||||||
|
>`
|
||||||
|
SELECT
|
||||||
|
"id",
|
||||||
|
"storeId",
|
||||||
|
"name",
|
||||||
|
"intent",
|
||||||
|
"mood",
|
||||||
|
"description",
|
||||||
|
"body",
|
||||||
|
"variables",
|
||||||
|
"isHtml",
|
||||||
|
"updatedAt"
|
||||||
|
FROM "Template"
|
||||||
|
WHERE "storeId" = ${storeId}
|
||||||
|
AND "intent" = 'general'
|
||||||
|
AND "mood" = 'neutral'
|
||||||
|
ORDER BY "updatedAt" DESC
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
const template = templateRows[0]
|
||||||
|
|
||||||
|
if (template) {
|
||||||
|
const store = await prisma.store.findUnique({
|
||||||
|
where: { id: storeId },
|
||||||
|
select: {
|
||||||
|
businessName: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
customer_name: senderEmail.split("@")[0],
|
||||||
|
customer_email: senderEmail,
|
||||||
|
subject: emailSubject,
|
||||||
|
store_name: store?.businessName || "Your store",
|
||||||
|
store_email: store?.email || "",
|
||||||
|
ticket_summary: emailBody.slice(0, 500),
|
||||||
|
}
|
||||||
|
|
||||||
|
finalReply = template.isHtml
|
||||||
|
? renderTemplate(template.body, context)
|
||||||
|
: `
|
||||||
|
<div style="font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.6; color: #0f172a;">
|
||||||
|
<pre style="white-space: pre-wrap; margin: 0;">${renderTemplate(
|
||||||
|
template.body,
|
||||||
|
context,
|
||||||
|
)}</pre>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
} catch (templateError) {
|
||||||
|
logger.error("Failed to render template for auto-reply", templateError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: simple hardcoded automated reply if no template is available
|
||||||
|
if (!finalReply) {
|
||||||
|
const safeSnippet = emailBody.replace(/<[^>]+>/g, "").slice(0, 400)
|
||||||
|
finalReply = `
|
||||||
|
<div style="font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.6; color: #0f172a; background-color: #0b1220;">
|
||||||
|
<div style="max-width: 640px; margin: 0 auto; background: #020617; border-radius: 16px; border: 1px solid #1f2937; padding: 24px;">
|
||||||
|
<h1 style="margin: 0 0 12px; font-size: 20px; color: #e5e7eb;">Thanks for reaching out 👋</h1>
|
||||||
|
<p style="margin: 0 0 12px; color: #9ca3af;">
|
||||||
|
We’ve received your message and our team will review it shortly.
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0 0 16px; color: #9ca3af;">
|
||||||
|
Here’s a quick summary of what you sent:
|
||||||
|
</p>
|
||||||
|
<blockquote style="margin: 0 0 20px; padding: 12px 16px; border-left: 3px solid #6366f1; background: #020617;">
|
||||||
|
<p style="margin: 0; white-space: pre-wrap; color: #d1d5db; font-size: 14px;">
|
||||||
|
${safeSnippet || "No additional message content was detected."}
|
||||||
|
</p>
|
||||||
|
</blockquote>
|
||||||
|
<p style="margin: 0 0 8px; color: #9ca3af;">
|
||||||
|
You don’t need to reply to this email unless you have more details to share.
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; color: #e5e7eb;">
|
||||||
|
Best regards,<br/>
|
||||||
|
<span style="color: #a5b4fc;">JUNOHUB Support Team</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the reply
|
||||||
|
const replySubject = emailSubject.startsWith("Re:")
|
||||||
|
? emailSubject
|
||||||
|
: `Re: ${emailSubject}`
|
||||||
|
|
||||||
|
const result = await sendGmailReply(
|
||||||
|
userId,
|
||||||
|
senderEmail,
|
||||||
|
replySubject,
|
||||||
|
finalReply,
|
||||||
|
email.threadId,
|
||||||
|
{
|
||||||
|
storeId,
|
||||||
|
isAutomated: true,
|
||||||
|
trigger: "GMAIL_WEBHOOK",
|
||||||
|
originalMessageId: email.messageId,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Always attempt to mark the email as read so it isn't processed again
|
||||||
|
const markedRead = await markEmailAsRead(accessToken, email.id)
|
||||||
|
if (!markedRead) {
|
||||||
|
logger.warn("Failed to mark email as read after processing", {
|
||||||
|
userId,
|
||||||
|
storeId,
|
||||||
|
emailId: email.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
logger.info("Automated reply sent successfully", {
|
||||||
|
userId,
|
||||||
|
storeId,
|
||||||
|
to: senderEmail,
|
||||||
|
subject: emailSubject,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.error("Failed to send automated reply", {
|
||||||
|
userId,
|
||||||
|
storeId,
|
||||||
|
to: senderEmail,
|
||||||
|
subject: emailSubject,
|
||||||
|
error: result.error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: result.success, error: result.error }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error processing incoming email", error)
|
||||||
|
return { success: false, error: error instanceof Error ? error.message : "Unknown error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unread emails from Gmail
|
||||||
|
*/
|
||||||
|
async function getUnreadEmails(
|
||||||
|
accessToken: string,
|
||||||
|
maxResults: number = 10
|
||||||
|
): Promise<Array<{
|
||||||
|
id: string
|
||||||
|
threadId: string
|
||||||
|
from: string
|
||||||
|
subject: string
|
||||||
|
snippet: string
|
||||||
|
messageId: string
|
||||||
|
}>> {
|
||||||
|
try {
|
||||||
|
const searchResponse = await fetch(
|
||||||
|
// Only consider unread emails from the last 10 minutes
|
||||||
|
`https://gmail.googleapis.com/gmail/v1/users/me/messages?q=is:unread newer_than:10m&maxResults=${maxResults}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!searchResponse.ok) {
|
||||||
|
logger.error("Failed to search Gmail messages")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchResult = await searchResponse.json()
|
||||||
|
const messages = searchResult.messages || []
|
||||||
|
|
||||||
|
const emailDetails = await Promise.all(
|
||||||
|
messages.map(async (msg: { id: string; threadId: string }) => {
|
||||||
|
try {
|
||||||
|
const messageResponse = await fetch(
|
||||||
|
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${msg.id}?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date&metadataHeaders=Message-ID`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!messageResponse.ok) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageData = await messageResponse.json()
|
||||||
|
const headers = messageData.payload?.headers || []
|
||||||
|
const fromHeader = headers.find((h: { name: string }) => h.name.toLowerCase() === "from")
|
||||||
|
const subjectHeader = headers.find((h: { name: string }) => h.name.toLowerCase() === "subject")
|
||||||
|
const messageIdHeader = headers.find((h: { name: string }) => h.name.toLowerCase() === "message-id")
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: msg.id,
|
||||||
|
threadId: msg.threadId,
|
||||||
|
from: fromHeader?.value || "",
|
||||||
|
subject: subjectHeader?.value || "",
|
||||||
|
snippet: messageData.snippet || "",
|
||||||
|
messageId: messageIdHeader?.value || ""
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error fetching message details", { error, messageId: msg.id })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return emailDetails.filter((email) => email !== null) as Array<{
|
||||||
|
id: string
|
||||||
|
threadId: string
|
||||||
|
from: string
|
||||||
|
subject: string
|
||||||
|
snippet: string
|
||||||
|
messageId: string
|
||||||
|
}>
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error getting unread emails", error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full email content
|
||||||
|
*/
|
||||||
|
async function getEmailContent(accessToken: string, messageId: string): Promise<any | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}?format=full`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error fetching email content", { error, messageId })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract email body from Gmail message
|
||||||
|
*/
|
||||||
|
function extractEmailBody(message: any): string {
|
||||||
|
try {
|
||||||
|
const payload = message.payload
|
||||||
|
if (!payload) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer text/plain, fall back to text/html, then snippet
|
||||||
|
const decodePart = (part: any): string => {
|
||||||
|
if (part.body?.data) {
|
||||||
|
return Buffer.from(part.body.data, "base64url").toString("utf-8")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectParts = (part: any, mimeType: string): string[] => {
|
||||||
|
const results: string[] = []
|
||||||
|
if (part.mimeType === mimeType && part.body?.data) {
|
||||||
|
results.push(decodePart(part))
|
||||||
|
}
|
||||||
|
if (Array.isArray(part.parts)) {
|
||||||
|
for (const p of part.parts) {
|
||||||
|
results.push(...collectParts(p, mimeType))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
let body =
|
||||||
|
collectParts(payload, "text/plain").join("\n").trim() ||
|
||||||
|
collectParts(payload, "text/html").join("\n").trim() ||
|
||||||
|
message.snippet ||
|
||||||
|
""
|
||||||
|
|
||||||
|
// Strip common Gmail quotation patterns so tickets only show the latest reply
|
||||||
|
const lines = body.split(/\r?\n/)
|
||||||
|
const cleanedLines: string[] = []
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
// Skip quoted lines and stop when we hit the typical "On ... wrote:" header
|
||||||
|
if (trimmed.startsWith(">")) continue
|
||||||
|
if (/^on .+wrote:$/i.test(trimmed)) break
|
||||||
|
cleanedLines.push(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
body = cleanedLines.join("\n").trim()
|
||||||
|
|
||||||
|
return body
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error extracting email body", error)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark email as read
|
||||||
|
*/
|
||||||
|
async function markEmailAsRead(accessToken: string, messageId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}/modify`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
removeLabelIds: ["UNREAD"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.ok
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error marking email as read", error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's email address from Gmail integration
|
||||||
|
*/
|
||||||
|
async function getUserEmail(userId: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const integration = await (prisma as any).gmailIntegration.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
select: { email: true }
|
||||||
|
})
|
||||||
|
return integration?.email || null
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error getting user email", error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -79,7 +79,19 @@ export async function sendGmailReply(
|
|||||||
body: JSON.stringify(requestBody)
|
body: JSON.stringify(requestBody)
|
||||||
})
|
})
|
||||||
|
|
||||||
const bodyPreview = body.replace(/<[^>]+>/g, "").slice(0, 280)
|
// Store a longer, cleaner plain-text version of the outgoing email for ticket view
|
||||||
|
let bodyPreview = body
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
||||||
|
.replace(/<[^>]+>/g, "")
|
||||||
|
.replace(/\s+\n/g, "\n")
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
// Normalize indentation so lines align nicely in the chat UI
|
||||||
|
bodyPreview = bodyPreview
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trimStart())
|
||||||
|
.join("\n")
|
||||||
const baseLogData = {
|
const baseLogData = {
|
||||||
userId,
|
userId,
|
||||||
storeId: options.storeId || null,
|
storeId: options.storeId || null,
|
||||||
|
|||||||
91
src/lib/gmail/poller.ts
Normal file
91
src/lib/gmail/poller.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { logger } from "@/lib/logger"
|
||||||
|
import { processIncomingEmail } from "@/lib/gmail/emailProcessor"
|
||||||
|
|
||||||
|
let pollerStarted = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a single polling cycle for all users who have GmailIntegration configured.
|
||||||
|
* For each user, we look up their active store and call processIncomingEmail,
|
||||||
|
* which will:
|
||||||
|
* - Fetch unread emails
|
||||||
|
* - Generate an AI or fallback reply
|
||||||
|
* - Send the reply and mark emails as read
|
||||||
|
* - Log into EmailLog for ticket views
|
||||||
|
*/
|
||||||
|
export async function pollAllGmailOnce() {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const integrations = await (prisma as any).gmailIntegration.findMany({
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
include: {
|
||||||
|
stores: {
|
||||||
|
where: { isActive: true },
|
||||||
|
take: 1,
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!integrations.length) {
|
||||||
|
logger.info("Gmail poller: no gmailIntegration records found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Gmail poller: starting poll cycle", {
|
||||||
|
integrations: integrations.length,
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const integration of integrations) {
|
||||||
|
const userId: string = integration.userId
|
||||||
|
const storeId: string | null = integration.user.stores[0]?.id ?? null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await processIncomingEmail(userId, storeId)
|
||||||
|
|
||||||
|
logger.info("Gmail poller: processed user", {
|
||||||
|
userId,
|
||||||
|
storeId,
|
||||||
|
success: result.success,
|
||||||
|
error: result.error,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Gmail poller: error processing user", {
|
||||||
|
userId,
|
||||||
|
storeId,
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Gmail poller: failed to query integrations", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a simple in-process cron-style poller.
|
||||||
|
*
|
||||||
|
* IMPORTANT: This should only be started once per Node.js process.
|
||||||
|
* We guard with a flag and call it from the /api/health route so it
|
||||||
|
* starts when the container's health check hits the app.
|
||||||
|
*/
|
||||||
|
export function startGmailPoller() {
|
||||||
|
if (pollerStarted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pollerStarted = true
|
||||||
|
|
||||||
|
const intervalMs =
|
||||||
|
Number.parseInt(process.env.GMAIL_POLL_INTERVAL_MS || "", 10) || 60_000
|
||||||
|
|
||||||
|
logger.info("Gmail poller: starting interval", { intervalMs })
|
||||||
|
|
||||||
|
// Fire-and-forget interval; errors are logged inside pollAllGmailOnce
|
||||||
|
setInterval(() => {
|
||||||
|
void pollAllGmailOnce()
|
||||||
|
}, intervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
87
src/lib/kb/bedrockChat.ts
Normal file
87
src/lib/kb/bedrockChat.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { BedrockRuntimeClient, InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime"
|
||||||
|
import { logger } from "@/lib/logger"
|
||||||
|
|
||||||
|
const AWS_REGION = process.env.AWS_REGION || "us-east-1"
|
||||||
|
const BEDROCK_CHAT_MODEL =
|
||||||
|
process.env.BEDROCK_CHAT_MODEL || "anthropic.claude-3-haiku-20240307-v1:0"
|
||||||
|
|
||||||
|
// Initialize Bedrock client
|
||||||
|
const bedrockClient = new BedrockRuntimeClient({
|
||||||
|
region: AWS_REGION,
|
||||||
|
credentials: process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY
|
||||||
|
? {
|
||||||
|
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||||
|
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||||
|
}
|
||||||
|
: undefined, // Will use default credential chain (IAM role, etc.)
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface ChatCompletionOptions {
|
||||||
|
systemPrompt?: string
|
||||||
|
maxTokens?: number
|
||||||
|
temperature?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function chatCompletion(
|
||||||
|
userMessage: string,
|
||||||
|
options: ChatCompletionOptions = {},
|
||||||
|
): Promise<string> {
|
||||||
|
const {
|
||||||
|
systemPrompt = "",
|
||||||
|
maxTokens = 512,
|
||||||
|
temperature = 0.7,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Claude Messages API format
|
||||||
|
const requestBody = {
|
||||||
|
anthropic_version: "bedrock-2023-05-31",
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
temperature: temperature,
|
||||||
|
system: systemPrompt,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user" as const,
|
||||||
|
content: userMessage,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = new InvokeModelCommand({
|
||||||
|
modelId: BEDROCK_CHAT_MODEL,
|
||||||
|
contentType: "application/json",
|
||||||
|
accept: "application/json",
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await bedrockClient.send(command)
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
logger.error("Empty response body from Bedrock chat API")
|
||||||
|
return "I am unable to generate an answer at this time."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse response body
|
||||||
|
const responseBody = JSON.parse(
|
||||||
|
new TextDecoder().decode(response.body),
|
||||||
|
) as {
|
||||||
|
content?: Array<{
|
||||||
|
type?: string
|
||||||
|
text?: string
|
||||||
|
}>
|
||||||
|
stop_reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract text from response
|
||||||
|
const textContent = responseBody.content
|
||||||
|
?.filter((item) => item.type === "text")
|
||||||
|
.map((item) => item.text || "")
|
||||||
|
.join("")
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
return textContent || "I am unable to generate an answer at this time."
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Bedrock chat completion failed", error)
|
||||||
|
return "I am unable to generate an answer at this time."
|
||||||
|
}
|
||||||
|
}
|
||||||
168
src/lib/kb/bedrockEmbeddings.ts
Normal file
168
src/lib/kb/bedrockEmbeddings.ts
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import { BedrockRuntimeClient, InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime"
|
||||||
|
import { logger } from "@/lib/logger"
|
||||||
|
|
||||||
|
const AWS_REGION = process.env.AWS_REGION || "us-east-1"
|
||||||
|
// Hard-code Titan Text Embeddings v2 so all embeddings go through this model
|
||||||
|
const BEDROCK_EMBEDDING_MODEL = "amazon.titan-embed-text-v2:0"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AWS Bedrock Rate Limits:
|
||||||
|
* - Titan Text Embeddings V2: 2,000 requests/minute (~33 req/sec)
|
||||||
|
* - We use 2-second delays between requests (30 req/min) to stay well under the limit
|
||||||
|
* - Throttling errors trigger exponential backoff: 5s, 10s, 20s, 40s, 80s
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Initialize Bedrock client with retry configuration
|
||||||
|
const bedrockClient = new BedrockRuntimeClient({
|
||||||
|
region: AWS_REGION,
|
||||||
|
credentials: process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY
|
||||||
|
? {
|
||||||
|
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||||
|
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||||
|
}
|
||||||
|
: undefined, // Will use default credential chain (IAM role, etc.)
|
||||||
|
maxAttempts: 5, // AWS SDK default retry attempts
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!process.env.AWS_ACCESS_KEY_ID && !process.env.AWS_SECRET_ACCESS_KEY) {
|
||||||
|
logger.warn(
|
||||||
|
"AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are not set. Bedrock will use default credential chain.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry helper with exponential backoff for throttling errors
|
||||||
|
*/
|
||||||
|
async function retryWithBackoff<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
maxRetries: number = 5,
|
||||||
|
baseDelay: number = 5000, // Start with 5 seconds for throttling
|
||||||
|
): Promise<T> {
|
||||||
|
let lastError: unknown
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fn()
|
||||||
|
} catch (error: any) {
|
||||||
|
lastError = error
|
||||||
|
|
||||||
|
// Check if it's a throttling error
|
||||||
|
const isThrottling =
|
||||||
|
error?.name === "ThrottlingException" ||
|
||||||
|
error?.$metadata?.httpStatusCode === 429 ||
|
||||||
|
error?.message?.includes("Too many requests")
|
||||||
|
|
||||||
|
if (isThrottling && attempt < maxRetries) {
|
||||||
|
// Exponential backoff with longer delays: 5s, 10s, 20s, 40s, 80s
|
||||||
|
const delay = baseDelay * Math.pow(2, attempt)
|
||||||
|
logger.warn(
|
||||||
|
`Bedrock throttling detected, retrying in ${delay / 1000}s (attempt ${attempt + 1}/${maxRetries + 1})`,
|
||||||
|
)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not throttling or max retries reached, throw
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function embedBatch(texts: string[]): Promise<number[][]> {
|
||||||
|
if (texts.length === 0) return []
|
||||||
|
|
||||||
|
const results: number[][] = []
|
||||||
|
// AWS Bedrock Titan V2 limit: 2000 requests/minute = ~33 req/sec
|
||||||
|
// Use 2 seconds delay to stay well under limit (30 req/min = safe margin)
|
||||||
|
const REQUEST_DELAY = 2000 // 2 seconds between requests
|
||||||
|
|
||||||
|
for (let i = 0; i < texts.length; i++) {
|
||||||
|
const text = texts[i]
|
||||||
|
const trimmed = text.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
results.push([])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Titan Text Embeddings V2 request format
|
||||||
|
const requestBody = {
|
||||||
|
inputText: trimmed,
|
||||||
|
dimensions: 1024, // Use 1024 dimensions to match database schema
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = new InvokeModelCommand({
|
||||||
|
modelId: BEDROCK_EMBEDDING_MODEL,
|
||||||
|
contentType: "application/json",
|
||||||
|
accept: "application/json",
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use retry logic with exponential backoff
|
||||||
|
const response = await retryWithBackoff(async () => {
|
||||||
|
return await bedrockClient.send(command)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
logger.error("Empty response body from Bedrock embedding API")
|
||||||
|
results.push([])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse response body
|
||||||
|
const responseBody = JSON.parse(
|
||||||
|
new TextDecoder().decode(response.body),
|
||||||
|
) as {
|
||||||
|
embedding?: number[]
|
||||||
|
inputTokenCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const vector = responseBody.embedding
|
||||||
|
if (!vector || !Array.isArray(vector)) {
|
||||||
|
logger.error("Invalid embedding response from Bedrock", responseBody)
|
||||||
|
results.push([])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify dimension matches expected (1024)
|
||||||
|
if (vector.length !== 1024) {
|
||||||
|
logger.warn(
|
||||||
|
`Expected 1024 dimensions but got ${vector.length}. Truncating or padding as needed.`,
|
||||||
|
)
|
||||||
|
// Pad or truncate to 1024
|
||||||
|
if (vector.length < 1024) {
|
||||||
|
vector.push(...new Array(1024 - vector.length).fill(0))
|
||||||
|
} else {
|
||||||
|
vector.splice(1024)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(vector)
|
||||||
|
|
||||||
|
// Add delay between requests to avoid rate limiting (except for last request)
|
||||||
|
if (i < texts.length - 1) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, REQUEST_DELAY))
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
const isThrottling =
|
||||||
|
error?.name === "ThrottlingException" ||
|
||||||
|
error?.$metadata?.httpStatusCode === 429 ||
|
||||||
|
error?.message?.includes("Too many requests")
|
||||||
|
|
||||||
|
if (isThrottling) {
|
||||||
|
logger.error(
|
||||||
|
`Bedrock throttling error after retries for text ${i + 1}/${texts.length}`,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logger.error(`Bedrock embedding request failed for text ${i + 1}/${texts.length}`, error)
|
||||||
|
}
|
||||||
|
// On any error, push an empty vector and continue.
|
||||||
|
results.push([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { logger } from "@/lib/logger"
|
import { logger } from "@/lib/logger"
|
||||||
import { embedBatch } from "@/lib/kb/geminiEmbeddings"
|
import { embedBatch } from "@/lib/kb/bedrockEmbeddings"
|
||||||
import type { TextChunk } from "@/lib/kb/documentProcessing"
|
import type { TextChunk } from "@/lib/kb/documentProcessing"
|
||||||
|
|
||||||
export async function indexDocumentChunks(chunks: TextChunk[]) {
|
export async function indexDocumentChunks(chunks: TextChunk[]) {
|
||||||
|
|||||||
35
src/lib/templates/simpleRenderer.ts
Normal file
35
src/lib/templates/simpleRenderer.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { logger } from "@/lib/logger"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Very small, safe renderer for {{variable}} placeholders.
|
||||||
|
* - No conditionals or loops
|
||||||
|
* - Only replaces {{key}} with string values from context
|
||||||
|
*/
|
||||||
|
export function renderTemplate(
|
||||||
|
body: string,
|
||||||
|
context: Record<string, string | number | boolean | null | undefined>,
|
||||||
|
): string {
|
||||||
|
if (!body) return ""
|
||||||
|
|
||||||
|
// Track missing variables for logging
|
||||||
|
const missing = new Set<string>()
|
||||||
|
|
||||||
|
const rendered = body.replace(/{{\s*([a-zA-Z0-9_.-]+)\s*}}/g, (match, key: string) => {
|
||||||
|
const value = context[key]
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
missing.add(key)
|
||||||
|
// Leave the placeholder in place so it's obvious something is missing
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (missing.size > 0) {
|
||||||
|
logger.warn("Template rendered with missing variables", {
|
||||||
|
missing: Array.from(missing),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return rendered
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user