Set up JUNOHUB app and EC2 deployment
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
f666c25eea
commit
284fb12576
115
.github/workflows/deploy-ec2.yml
vendored
Normal file
115
.github/workflows/deploy-ec2.yml
vendored
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
name: Deploy to EC2
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_ENV: production
|
||||||
|
EC2_ENV: ${{ secrets.EC2_ENV }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Use Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm test -- --watch=false || echo "Tests step is configurable; adjust as needed"
|
||||||
|
|
||||||
|
- name: Build app
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Deploy to EC2 with rollback
|
||||||
|
uses: appleboy/ssh-action@v1.2.0
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.EC2_HOST }}
|
||||||
|
username: ${{ secrets.EC2_USER }}
|
||||||
|
key: ${{ secrets.EC2_SSH_KEY }}
|
||||||
|
port: ${{ secrets.EC2_SSH_PORT || '22' }}
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
|
||||||
|
APP_DIR="${APP_DIR:-/var/www/pdc-poc}"
|
||||||
|
|
||||||
|
echo "Using app directory: $APP_DIR"
|
||||||
|
mkdir -p "$APP_DIR"
|
||||||
|
cd "$APP_DIR"
|
||||||
|
|
||||||
|
echo "Writing .env file from EC2_ENV secret..."
|
||||||
|
# Overwrite .env on the server with the contents of the EC2_ENV variable
|
||||||
|
# EC2_ENV is provided by GitHub as a multi-line secret matching your local .env
|
||||||
|
printf '%s\n' "$EC2_ENV" > .env
|
||||||
|
|
||||||
|
if [ ! -d ".git" ]; then
|
||||||
|
echo "Cloning repository for the first time..."
|
||||||
|
git clone ${{ github.repositoryUrl }} .
|
||||||
|
fi
|
||||||
|
|
||||||
|
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 "Pulling docker images and rebuilding containers..."
|
||||||
|
if command -v docker-compose >/dev/null 2>&1; then
|
||||||
|
DC="docker-compose"
|
||||||
|
else
|
||||||
|
DC="docker compose"
|
||||||
|
fi
|
||||||
|
|
||||||
|
$DC pull || true
|
||||||
|
|
||||||
|
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"
|
||||||
|
$DC up -d --build || true
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Running health check..."
|
||||||
|
HEALTH_URL="${HEALTH_URL:-http://localhost:3000/api/health}"
|
||||||
|
|
||||||
|
for i in {1..10}; do
|
||||||
|
if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then
|
||||||
|
echo "Health check passed."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Health check attempt $i failed, retrying..."
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Health check failed after multiple attempts, rolling back..."
|
||||||
|
if [ -n "$PREV_COMMIT" ]; then
|
||||||
|
git reset --hard "$PREV_COMMIT"
|
||||||
|
$DC up -d --build || true
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
|
||||||
56
.gitignore
vendored
Normal file
56
.gitignore
vendored
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
/dist
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# generated / local artifacts
|
||||||
|
/src/generated/prisma
|
||||||
|
/.turbo
|
||||||
|
/.vite
|
||||||
|
|
||||||
|
# prisma
|
||||||
|
/prisma/dev.db
|
||||||
|
/prisma/dev.db-journal
|
||||||
|
|
||||||
|
# docker
|
||||||
|
*.log
|
||||||
|
docker-data/
|
||||||
|
|
||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json prisma.config.ts ./
|
||||||
|
COPY prisma ./prisma
|
||||||
|
|
||||||
|
RUN npm install && npx prisma generate
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
164
Documents/GMAIL_SETUP.md
Normal file
164
Documents/GMAIL_SETUP.md
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
# Gmail Integration Setup Guide
|
||||||
|
|
||||||
|
This guide explains how to set up Gmail integration so that **every vendor can connect their own Gmail account** to receive and send customer emails.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
- **One OAuth App**: You create ONE Google OAuth application in Google Cloud Console
|
||||||
|
- **Shared Credentials**: All vendors use the same `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`
|
||||||
|
- **Individual Authorization**: Each vendor authorizes their own Gmail account when connecting
|
||||||
|
- **Separate Tokens**: Each vendor's Gmail tokens are stored separately in the database
|
||||||
|
|
||||||
|
## Login Method vs Gmail Integration
|
||||||
|
|
||||||
|
**IMPORTANT**: These are TWO separate things:
|
||||||
|
|
||||||
|
1. **Login Method** (How vendor logs into YOUR app):
|
||||||
|
- Email/Password login ✅
|
||||||
|
- Google OAuth login ✅
|
||||||
|
- Both work independently
|
||||||
|
|
||||||
|
2. **Gmail Integration** (Authorizing Gmail API access):
|
||||||
|
- Separate OAuth flow
|
||||||
|
- Works regardless of login method
|
||||||
|
- Vendor can connect ANY Gmail account (doesn't need to match login email)
|
||||||
|
|
||||||
|
### Example Scenarios:
|
||||||
|
|
||||||
|
**Scenario 1**: Vendor logs in with email/password (`talatfaheem2@gmail.com`)
|
||||||
|
- ✅ Can connect Gmail integration
|
||||||
|
- When clicking "Connect Gmail", they'll be redirected to Google OAuth
|
||||||
|
- Can authorize the same Gmail (`talatfaheem2@gmail.com`) OR a different one (`business@gmail.com`)
|
||||||
|
|
||||||
|
**Scenario 2**: Vendor logs in with Google OAuth (`talatfaheem2@gmail.com`)
|
||||||
|
- ✅ Can connect Gmail integration
|
||||||
|
- When clicking "Connect Gmail", they'll be redirected to Google OAuth again
|
||||||
|
- Can authorize the same Gmail (`talatfaheem2@gmail.com`) OR a different one (`business@gmail.com`)
|
||||||
|
|
||||||
|
**Key Point**: The Gmail account they authorize doesn't need to match their login email!
|
||||||
|
|
||||||
|
## Step-by-Step Setup
|
||||||
|
|
||||||
|
### 1. Create Google Cloud Project (One Time Setup)
|
||||||
|
|
||||||
|
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||||
|
2. Create a new project (or select existing)
|
||||||
|
3. Enable **Gmail API**:
|
||||||
|
- Go to [APIs & Services > Library](https://console.cloud.google.com/apis/library)
|
||||||
|
- Search for "Gmail API"
|
||||||
|
- Click "Enable"
|
||||||
|
|
||||||
|
### 2. Create OAuth 2.0 Credentials
|
||||||
|
|
||||||
|
1. Go to [APIs & Services > Credentials](https://console.cloud.google.com/apis/credentials)
|
||||||
|
2. Click "Create Credentials" > "OAuth client ID"
|
||||||
|
3. **If prompted**, configure OAuth consent screen (Google will prompt you the first time):
|
||||||
|
- Choose "External" (unless you have Google Workspace)
|
||||||
|
- Fill in app name, support email, developer contact
|
||||||
|
- **Note**: You don't need to manually add scopes here - they're automatically requested when vendors connect
|
||||||
|
- **IMPORTANT**: Add test users - Add ALL Gmail addresses that will connect (e.g., `krypttech4@gmail.com`, `talatfaheem2@gmail.com`, etc.)
|
||||||
|
- Click "Save and Continue" through the steps
|
||||||
|
|
||||||
|
**OR if consent screen is already configured:**
|
||||||
|
- Go to [APIs & Services > OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent)
|
||||||
|
- Scroll down to "Test users" section
|
||||||
|
- Click "+ ADD USERS"
|
||||||
|
- Add the Gmail addresses that will connect (one per line or comma-separated)
|
||||||
|
- Click "Add"
|
||||||
|
4. Create OAuth client:
|
||||||
|
- Application type: "Web application"
|
||||||
|
- Name: "JunoHub Gmail Integration" (or your choice)
|
||||||
|
- **Authorized redirect URIs** (CRITICAL - must match exactly):
|
||||||
|
```
|
||||||
|
http://localhost:3000/api/integrations/gmail/callback
|
||||||
|
https://your-production-domain.com/api/integrations/gmail/callback
|
||||||
|
```
|
||||||
|
- If using ngrok: `https://your-ngrok-url.ngrok-free.dev/api/integrations/gmail/callback`
|
||||||
|
- **Important**: Add ALL environments you'll use (dev, staging, production, ngrok URLs)
|
||||||
|
|
||||||
|
### 3. Configure Environment Variables
|
||||||
|
|
||||||
|
Add to your `.env` file:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Your Google Cloud OAuth credentials (shared for all vendors)
|
||||||
|
GOOGLE_CLIENT_ID="your-client-id-here.apps.googleusercontent.com"
|
||||||
|
GOOGLE_CLIENT_SECRET="your-client-secret-here"
|
||||||
|
|
||||||
|
# Your app URL (used for OAuth redirects)
|
||||||
|
NEXTAUTH_URL="http://localhost:3000" # or your production URL
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Test the Integration
|
||||||
|
|
||||||
|
1. Start your application
|
||||||
|
2. Log in as a vendor
|
||||||
|
3. Go to Juno Engine > Gmail Connect
|
||||||
|
4. Click "Connect Gmail Account"
|
||||||
|
5. Sign in with **any Gmail account** (doesn't need to match the OAuth app owner)
|
||||||
|
6. Authorize the requested permissions
|
||||||
|
7. You should be redirected back and see "Gmail Connected"
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Error: `redirect_uri_mismatch`
|
||||||
|
|
||||||
|
**Problem**: The redirect URI in your code doesn't match what's configured in Google Cloud Console.
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Check the error message - it will show the redirect URI being used
|
||||||
|
2. Go to Google Cloud Console > Credentials > Your OAuth Client
|
||||||
|
3. Add the exact redirect URI shown in the error to "Authorized redirect URIs"
|
||||||
|
4. Wait a few minutes for changes to propagate
|
||||||
|
5. Try again
|
||||||
|
|
||||||
|
### Error: `invalid_client`
|
||||||
|
|
||||||
|
**Problem**: The `GOOGLE_CLIENT_ID` or `GOOGLE_CLIENT_SECRET` is incorrect.
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Double-check your `.env` file
|
||||||
|
2. Make sure there are no extra spaces or quotes
|
||||||
|
3. Restart your application after changing `.env`
|
||||||
|
|
||||||
|
### Error: `access_denied` or "Access blocked: has not completed the Google verification process"
|
||||||
|
|
||||||
|
**Problem**: The OAuth app is in "Testing" mode and the user's Gmail address is not added as a test user.
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Go to [APIs & Services > OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent)
|
||||||
|
2. Scroll down to "Test users" section
|
||||||
|
3. Click "+ ADD USERS"
|
||||||
|
4. Add the Gmail address that's trying to connect (e.g., `krypttech4@gmail.com`)
|
||||||
|
5. Click "Add"
|
||||||
|
6. Wait a few minutes for changes to propagate
|
||||||
|
7. Try connecting again
|
||||||
|
|
||||||
|
**Note**: In testing mode, you must add each Gmail address as a test user. For production, you'll need to publish your app (requires Google verification).
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
- **OAuth Consent Screen**: Google will prompt you to configure this the FIRST time you create OAuth credentials. After that, you can skip it. The scopes are automatically requested in the OAuth flow - you don't need to manually add them in the consent screen.
|
||||||
|
- **OAuth App Owner**: The email that created the OAuth app (`i221735@nu.edu.pk`) doesn't matter. Any Gmail account can authorize access.
|
||||||
|
- **Vendor Gmail**: Each vendor connects their own Gmail account (`talatfaheem2@gmail.com`, etc.). They don't need their own OAuth credentials.
|
||||||
|
- **Redirect URI**: Must match EXACTLY (including protocol, domain, port, path). No trailing slashes.
|
||||||
|
- **Multiple Environments**: Add redirect URIs for all environments (localhost, staging, production, ngrok).
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- OAuth tokens are stored securely in the database
|
||||||
|
- Each vendor's tokens are isolated (can't access each other's emails)
|
||||||
|
- Tokens are automatically refreshed when expired
|
||||||
|
- Vendors can disconnect their Gmail at any time
|
||||||
|
|
||||||
|
## Production Checklist
|
||||||
|
|
||||||
|
- [ ] Gmail API enabled in Google Cloud Console
|
||||||
|
- [ ] OAuth consent screen configured
|
||||||
|
- [ ] **Test users added** (for testing mode) - Add all Gmail addresses that will connect
|
||||||
|
- [ ] OAuth client created with correct redirect URIs
|
||||||
|
- [ ] Environment variables set (`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `NEXTAUTH_URL`)
|
||||||
|
- [ ] Database migration run (`prisma db push`)
|
||||||
|
- [ ] Tested with at least one vendor account
|
||||||
|
- [ ] Production redirect URI added to Google Cloud Console
|
||||||
|
- [ ] **For production**: Publish your app (requires Google verification) OR keep adding test users as needed
|
||||||
154
Documents/REMOVE_OAUTH_WARNING.md
Normal file
154
Documents/REMOVE_OAUTH_WARNING.md
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
# How to Remove "Google hasn't verified this app" Warning
|
||||||
|
|
||||||
|
## Quick Answer
|
||||||
|
|
||||||
|
The warning appears because your app requests **Gmail scopes** which are sensitive/restricted and require Google verification. You have 3 options:
|
||||||
|
|
||||||
|
## Option 1: Keep in Testing Mode (Easiest - For Development)
|
||||||
|
|
||||||
|
**Best for**: Development, testing, or apps with fewer than 100 users
|
||||||
|
|
||||||
|
### Steps:
|
||||||
|
1. Go to [Google Cloud Console > OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent)
|
||||||
|
2. Click **"Back to testing"** button (if your app is currently "In production")
|
||||||
|
3. Add all Gmail addresses as **test users**:
|
||||||
|
- Scroll to "Test users" section
|
||||||
|
- Click "+ ADD USERS"
|
||||||
|
- Add each Gmail address (one per line)
|
||||||
|
- Click "Add"
|
||||||
|
4. Save changes
|
||||||
|
|
||||||
|
### Result:
|
||||||
|
- ✅ Test users won't see the warning
|
||||||
|
- ✅ Can connect Gmail without verification
|
||||||
|
- ❌ Limited to 100 test users
|
||||||
|
- ❌ Must manually add each user
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option 2: Get Google Verification (For Production)
|
||||||
|
|
||||||
|
**Best for**: Production apps that need to scale beyond 100 users
|
||||||
|
|
||||||
|
### Prerequisites:
|
||||||
|
Before starting verification, you need:
|
||||||
|
|
||||||
|
1. **Privacy Policy** (Required)
|
||||||
|
- Must be publicly accessible URL
|
||||||
|
- Must explain how you use Gmail data
|
||||||
|
- Example: `https://yourdomain.com/privacy-policy`
|
||||||
|
|
||||||
|
2. **Terms of Service** (Required)
|
||||||
|
- Must be publicly accessible URL
|
||||||
|
- Example: `https://yourdomain.com/terms`
|
||||||
|
|
||||||
|
3. **App Homepage** (Required)
|
||||||
|
- Your app's main website URL
|
||||||
|
- Example: `https://yourdomain.com`
|
||||||
|
|
||||||
|
4. **Authorized Domains**
|
||||||
|
- Add your production domain
|
||||||
|
- Example: `yourdomain.com`
|
||||||
|
|
||||||
|
### Verification Steps:
|
||||||
|
|
||||||
|
1. **Go to Verification Center**:
|
||||||
|
- [OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent)
|
||||||
|
- Click "Verification Center" in left sidebar
|
||||||
|
- Or go directly: [Verification Center](https://console.cloud.google.com/apis/credentials/consent/verification)
|
||||||
|
|
||||||
|
2. **Start Verification**:
|
||||||
|
- Click "Start Verification" or "Submit for verification"
|
||||||
|
- Fill out the verification form
|
||||||
|
|
||||||
|
3. **Complete Verification Form**:
|
||||||
|
- **App name**: Your app name (e.g., "JunoHub")
|
||||||
|
- **Support email**: Your support email address
|
||||||
|
- **App domain**: Your production domain
|
||||||
|
- **Authorized domains**: Add your domain
|
||||||
|
- **Scopes**: Gmail scopes will be automatically listed
|
||||||
|
- **Privacy Policy URL**: Your privacy policy URL
|
||||||
|
- **Terms of Service URL**: Your terms of service URL
|
||||||
|
- **App homepage**: Your app's homepage URL
|
||||||
|
- **App logo**: Optional but recommended
|
||||||
|
|
||||||
|
4. **Additional Information** (may be requested):
|
||||||
|
- **Video demonstration**: Show how your app uses Gmail
|
||||||
|
- **Use case explanation**: Why you need Gmail access
|
||||||
|
- **Data handling**: How you store/process Gmail data
|
||||||
|
|
||||||
|
5. **Submit for Review**:
|
||||||
|
- Review all information
|
||||||
|
- Submit the form
|
||||||
|
- Google will review (typically 4-6 weeks)
|
||||||
|
|
||||||
|
### After Verification:
|
||||||
|
- ✅ Warning disappears for ALL users
|
||||||
|
- ✅ No user limit
|
||||||
|
- ✅ Can request sensitive/restricted scopes
|
||||||
|
- ✅ Professional appearance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option 3: Use Domain-Wide Delegation (Advanced - For Google Workspace)
|
||||||
|
|
||||||
|
**Best for**: Google Workspace organizations only
|
||||||
|
|
||||||
|
If you're using Google Workspace, you can use domain-wide delegation instead of OAuth, but this requires admin access and is more complex.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Status Check
|
||||||
|
|
||||||
|
Based on your Google Cloud Console screenshot:
|
||||||
|
- ✅ App is "In production"
|
||||||
|
- ✅ User type is "External"
|
||||||
|
- ⚠️ OAuth user cap: 0/100 users
|
||||||
|
- ⚠️ Warning appears because Gmail scopes are unapproved
|
||||||
|
|
||||||
|
## Recommended Action
|
||||||
|
|
||||||
|
**For now (Development/Testing)**:
|
||||||
|
1. Click "Back to testing" in OAuth consent screen
|
||||||
|
2. Add test users (your vendors' Gmail addresses)
|
||||||
|
3. Test users won't see the warning
|
||||||
|
|
||||||
|
**For later (Production)**:
|
||||||
|
1. Create Privacy Policy and Terms of Service pages
|
||||||
|
2. Submit app for Google verification
|
||||||
|
3. Wait for approval (4-6 weeks)
|
||||||
|
4. Warning will disappear automatically
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Privacy Policy Template (Gmail Data Usage)
|
||||||
|
|
||||||
|
Your Privacy Policy must mention Gmail data usage. Example section:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Gmail Integration
|
||||||
|
|
||||||
|
When you connect your Gmail account to [Your App Name], we access:
|
||||||
|
- Read emails: To sync customer emails and create support tickets
|
||||||
|
- Send emails: To send automated responses and notifications
|
||||||
|
- Modify emails: To manage email threads and mark emails as read
|
||||||
|
|
||||||
|
We store:
|
||||||
|
- OAuth tokens (encrypted) for Gmail API access
|
||||||
|
- Email addresses for account identification
|
||||||
|
|
||||||
|
We do NOT:
|
||||||
|
- Store email content permanently
|
||||||
|
- Share your emails with third parties
|
||||||
|
- Access emails outside of the app's functionality
|
||||||
|
|
||||||
|
You can revoke access at any time by disconnecting Gmail in your account settings.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Need Help?
|
||||||
|
|
||||||
|
- [Google OAuth Verification Guide](https://support.google.com/cloud/answer/9110914)
|
||||||
|
- [OAuth Consent Screen Documentation](https://developers.google.com/identity/protocols/oauth2/policy)
|
||||||
|
- [Gmail API Scopes Documentation](https://developers.google.com/gmail/api/auth/scopes)
|
||||||
28
Documents/faq_test.json
Normal file
28
Documents/faq_test.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"question": "What is your standard order processing time?",
|
||||||
|
"answer": "Orders are typically processed within 1–2 business days. During peak seasons, processing may take up to 3 business days.",
|
||||||
|
"tags": ["orders", "shipping", "processing_time"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "How long does shipping usually take?",
|
||||||
|
"answer": "Standard shipping within the US takes 3–7 business days after the order has been processed. International shipping can take 7–21 business days depending on the destination and carrier.",
|
||||||
|
"tags": ["shipping", "delivery"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "What is your return policy?",
|
||||||
|
"answer": "You can return most items within 30 days of delivery in their original condition for a full refund. Items marked as final sale are not eligible for returns.",
|
||||||
|
"tags": ["returns", "refunds", "policy"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "How do I start a return?",
|
||||||
|
"answer": "To start a return, go to your Orders page, select the order you want to return, and click \"Request Return\". Our team will email you a prepaid return label and instructions within 24 hours.",
|
||||||
|
"tags": ["returns", "how_to"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "Can I change or cancel my order after placing it?",
|
||||||
|
"answer": "Orders can be changed or canceled within 1 hour of placement. After that, we start processing and cannot guarantee changes. Please contact support as soon as possible with your order number.",
|
||||||
|
"tags": ["orders", "changes", "cancellations"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
124
README.md
Normal file
124
README.md
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
## JUNOHUB
|
||||||
|
|
||||||
|
JUNOHUB is a Next.js application that powers an admin and supplier portal with:
|
||||||
|
|
||||||
|
- Authentication and multi-factor auth
|
||||||
|
- Gmail integration for support tickets (Juno Engine)
|
||||||
|
- Shopify integration for orders, products, and inventory
|
||||||
|
- Knowledge base and FAQ management
|
||||||
|
- Admin dashboards for stores, suppliers, and email events
|
||||||
|
|
||||||
|
The frontend and backend are implemented in a single Next.js App Router project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Framework**: Next.js (App Router, TypeScript)
|
||||||
|
- **UI**: React + modern Tailwind-style components
|
||||||
|
- **Auth**: NextAuth
|
||||||
|
- **Database**: PostgreSQL (Prisma ORM)
|
||||||
|
- **Background / Integrations**:
|
||||||
|
- Gmail APIs (via OAuth)
|
||||||
|
- Shopify APIs
|
||||||
|
- Gemini for embeddings and chat
|
||||||
|
- **Deployment**: Docker + GitHub Actions → AWS EC2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
1. **Install dependencies**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create `.env`**
|
||||||
|
|
||||||
|
Copy `.env.example` (or the variables listed below) into a local `.env` file and fill in your own values:
|
||||||
|
|
||||||
|
- `DATABASE_URL`
|
||||||
|
- `NEXTAUTH_SECRET`
|
||||||
|
- `NEXTAUTH_URL`
|
||||||
|
- `GOOGLE_CLIENT_ID`
|
||||||
|
- `GOOGLE_CLIENT_SECRET`
|
||||||
|
- `SMTP_HOST`
|
||||||
|
- `SMTP_PORT`
|
||||||
|
- `SMTP_SECURE`
|
||||||
|
- `SMTP_USER`
|
||||||
|
- `SMTP_PASSWORD`
|
||||||
|
- `GEMINI_API_KEY`
|
||||||
|
- `GEMINI_EMBEDDING_MODEL`
|
||||||
|
- `GEMINI_CHAT_MODEL`
|
||||||
|
- `ADMIN_EMAIL`
|
||||||
|
- `ADMIN_PASSWORD`
|
||||||
|
|
||||||
|
> Never commit real secrets; `.env*` files are ignored by git.
|
||||||
|
|
||||||
|
3. **Run the dev server**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Visit `http://localhost:3000` in your browser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
The project can be run via Docker using the provided `Dockerfile` and `docker-compose.yml`.
|
||||||
|
|
||||||
|
Typical pattern:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Make sure your `.env` is present next to the app code so the container can read it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI/CD on AWS EC2
|
||||||
|
|
||||||
|
This repo includes a GitHub Actions workflow at `.github/workflows/deploy-ec2.yml` that:
|
||||||
|
|
||||||
|
1. Runs on pushes to the `main` branch.
|
||||||
|
2. Installs dependencies and builds the Next.js app.
|
||||||
|
3. SSHes into an EC2 instance.
|
||||||
|
4. Writes an `.env` file on the EC2 instance from a **single GitHub secret** `EC2_ENV`.
|
||||||
|
5. Uses `docker compose` / `docker-compose` to build and run the app.
|
||||||
|
6. Performs a health check and **automatically rolls back** to the previous commit if the deployment or health check fails.
|
||||||
|
|
||||||
|
### Required GitHub Secrets
|
||||||
|
|
||||||
|
Set the following secrets in **GitHub → Settings → Secrets and variables → Actions**:
|
||||||
|
|
||||||
|
- `EC2_HOST` – public IP or DNS of the EC2 instance.
|
||||||
|
- `EC2_USER` – SSH user (e.g. `ubuntu`).
|
||||||
|
- `EC2_SSH_KEY` – private SSH key for that user.
|
||||||
|
- `EC2_SSH_PORT` – (optional) port, defaults to `22` if not set.
|
||||||
|
- `EC2_ENV` – **multi-line secret containing the full contents of the `.env` file** used in production.
|
||||||
|
This is written to `.env` on the server during deployment.
|
||||||
|
|
||||||
|
### Deployment Flow
|
||||||
|
|
||||||
|
- Push to `main`.
|
||||||
|
- GitHub Actions:
|
||||||
|
- Builds the app.
|
||||||
|
- Connects to EC2.
|
||||||
|
- Writes `.env` from `EC2_ENV`.
|
||||||
|
- Runs `docker compose up -d --build`.
|
||||||
|
- Hits the health check URL (by default `http://localhost:3000/api/health`).
|
||||||
|
- If the health check fails, the workflow:
|
||||||
|
- Resets the repo on EC2 to the previous commit.
|
||||||
|
- Re-runs `docker compose up -d --build`.
|
||||||
|
|
||||||
|
This gives you a sharp deployment with an automatic rollback path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Private / All rights reserved unless explicitly stated otherwise.
|
||||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
# Use a Postgres image with pgvector preinstalled so we can enable the `vector` extension
|
||||||
|
image: pgvector/pgvector:pg17
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-dropshipping}
|
||||||
|
ports:
|
||||||
|
- "5444:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-password}@db:5432/${POSTGRES_DB:-dropshipping}?schema=public
|
||||||
|
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
|
||||||
|
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
|
||||||
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
|
||||||
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
30
next.config.ts
Normal file
30
next.config.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "*.shopify.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "cdn.shopify.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "*.myshopify.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Allow ngrok origin for development (fixes cross-origin warnings)
|
||||||
|
allowedDevOrigins: [
|
||||||
|
"https://precontributive-tribally-dione.ngrok-free.dev",
|
||||||
|
"https://*.ngrok-free.dev",
|
||||||
|
"https://*.ngrok.io",
|
||||||
|
],
|
||||||
|
reactCompiler: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
8042
package-lock.json
generated
Normal file
8042
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
53
package.json
Normal file
53
package.json
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"name": "pdc-poc",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@auth/core": "^0.41.0",
|
||||||
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
|
"@prisma/adapter-pg": "^7.3.0",
|
||||||
|
"@prisma/client": "^7.3.0",
|
||||||
|
"@types/nodemailer": "^7.0.9",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@upstash/ratelimit": "^2.0.5",
|
||||||
|
"@upstash/redis": "^1.34.3",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"dotenv": "^17.2.4",
|
||||||
|
"framer-motion": "^12.33.0",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
|
"next": "16.1.6",
|
||||||
|
"next-auth": "^5.0.0-beta.30",
|
||||||
|
"nodemailer": "^7.0.7",
|
||||||
|
"otplib": "^13.2.1",
|
||||||
|
"pg": "^8.18.0",
|
||||||
|
"prisma": "^7.3.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^3.0.0",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.1.6",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
14
prisma.config.ts
Normal file
14
prisma.config.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
// This file was generated by Prisma, and assumes you have installed the following:
|
||||||
|
// npm install --save-dev prisma dotenv
|
||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: process.env["DATABASE_URL"],
|
||||||
|
},
|
||||||
|
});
|
||||||
15
prisma/migrations/20260209_init_pgvector/steps.sql
Normal file
15
prisma/migrations/20260209_init_pgvector/steps.sql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
-- Enable pgvector extension and create index for knowledge chunk embeddings
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
|
||||||
|
-- Adjust the embedding column on KnowledgeChunk to use a concrete vector dimension.
|
||||||
|
-- NOTE: Update 1536 to match the actual embedding size used by your Gemini model.
|
||||||
|
ALTER TABLE "KnowledgeChunk"
|
||||||
|
ALTER COLUMN "embedding" TYPE vector(1536);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
463
prisma/schema.prisma
Normal file
463
prisma/schema.prisma
Normal file
@ -0,0 +1,463 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UserRole {
|
||||||
|
VENDOR
|
||||||
|
SUPPLIER
|
||||||
|
ADMIN
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String?
|
||||||
|
email String? @unique
|
||||||
|
emailVerified DateTime?
|
||||||
|
image String?
|
||||||
|
password String?
|
||||||
|
role UserRole @default(VENDOR)
|
||||||
|
|
||||||
|
activeStoreId String? // Track which store is currently active
|
||||||
|
|
||||||
|
// MFA (Multi-Factor Authentication)
|
||||||
|
mfaEnabled Boolean @default(false)
|
||||||
|
mfaSecret String? // Encrypted TOTP secret
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
accounts Account[]
|
||||||
|
sessions Session[]
|
||||||
|
stores Store[] // Changed from singular to support multiple stores
|
||||||
|
supplierProfile SupplierProfile?
|
||||||
|
notifications Notification[]
|
||||||
|
gmailIntegration GmailIntegration?
|
||||||
|
emailLogs EmailLog[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth.js / NextAuth Prisma adapter models (required for OAuth e.g. Google)
|
||||||
|
model Account {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
type String
|
||||||
|
provider String
|
||||||
|
providerAccountId String
|
||||||
|
refresh_token String? @db.Text
|
||||||
|
access_token String? @db.Text
|
||||||
|
expires_at Int?
|
||||||
|
token_type String?
|
||||||
|
scope String?
|
||||||
|
id_token String? @db.Text
|
||||||
|
session_state String?
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([provider, providerAccountId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sessionToken String @unique
|
||||||
|
userId String
|
||||||
|
expires DateTime
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model VerificationToken {
|
||||||
|
identifier String
|
||||||
|
token String
|
||||||
|
expires DateTime
|
||||||
|
|
||||||
|
@@unique([identifier, token])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Store {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String // Removed @unique to allow multiple stores per user
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
|
businessName String
|
||||||
|
email String
|
||||||
|
timezone String
|
||||||
|
country String
|
||||||
|
|
||||||
|
// Shopify Credentials
|
||||||
|
shopifyStoreName String
|
||||||
|
shopifyDomain String
|
||||||
|
shopifyApiKey String
|
||||||
|
shopifyApiSecret String
|
||||||
|
shopifyAccessToken String
|
||||||
|
|
||||||
|
onboardingComplete Boolean @default(false)
|
||||||
|
isActive Boolean @default(true) // Track if this is the active store
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
connections Connection[]
|
||||||
|
cachedProducts CachedProduct[]
|
||||||
|
cachedOrders CachedOrder[]
|
||||||
|
cachedCustomers CachedCustomer[]
|
||||||
|
syncMetadata SyncMetadata[]
|
||||||
|
invitations Invitation[]
|
||||||
|
productSyncs ProductSync[]
|
||||||
|
knowledgeDocuments KnowledgeDocument[]
|
||||||
|
faqItems FaqItem[]
|
||||||
|
knowledgeChunks KnowledgeChunk[]
|
||||||
|
emailLogs EmailLog[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([userId]) // Add index for performance
|
||||||
|
}
|
||||||
|
|
||||||
|
model CachedProduct {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
shopifyProductId String
|
||||||
|
storeId String
|
||||||
|
title String
|
||||||
|
vendor String?
|
||||||
|
productType String?
|
||||||
|
status String?
|
||||||
|
variants Json // Store variants as JSON
|
||||||
|
images Json // Store images as JSON
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
lastSyncedAt DateTime @default(now())
|
||||||
|
|
||||||
|
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([shopifyProductId, storeId])
|
||||||
|
@@index([storeId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CachedOrder {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
shopifyOrderId String
|
||||||
|
storeId String
|
||||||
|
orderNumber String
|
||||||
|
email String?
|
||||||
|
totalPrice String
|
||||||
|
currency String?
|
||||||
|
financialStatus String?
|
||||||
|
fulfillmentStatus String?
|
||||||
|
lineItems Json // Store line items as JSON
|
||||||
|
customer Json? // Store customer info as JSON
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
lastSyncedAt DateTime @default(now())
|
||||||
|
shopifyCreatedAt DateTime?
|
||||||
|
|
||||||
|
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([shopifyOrderId, storeId])
|
||||||
|
@@index([storeId])
|
||||||
|
@@index([shopifyCreatedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CachedCustomer {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
shopifyCustomerId String
|
||||||
|
storeId String
|
||||||
|
email String?
|
||||||
|
firstName String?
|
||||||
|
lastName String?
|
||||||
|
ordersCount Int @default(0)
|
||||||
|
totalSpent String?
|
||||||
|
phone String?
|
||||||
|
tags String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
lastSyncedAt DateTime @default(now())
|
||||||
|
shopifyCreatedAt DateTime?
|
||||||
|
|
||||||
|
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([shopifyCustomerId, storeId])
|
||||||
|
@@index([storeId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SyncMetadata {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
storeId String
|
||||||
|
resourceType String // "products", "orders", "customers"
|
||||||
|
lastSyncAt DateTime @default(now())
|
||||||
|
syncStatus String @default("success") // "success", "failed", "in_progress"
|
||||||
|
recordCount Int @default(0)
|
||||||
|
errorMessage String?
|
||||||
|
|
||||||
|
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([storeId, resourceType])
|
||||||
|
@@index([storeId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SupplierProfile {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
|
companyName String
|
||||||
|
description String?
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
connections Connection[]
|
||||||
|
products Product[]
|
||||||
|
invitations Invitation[]
|
||||||
|
productSyncs ProductSync[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Connection {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
storeId String
|
||||||
|
store Store @relation(fields: [storeId], references: [id])
|
||||||
|
supplierId String
|
||||||
|
supplier SupplierProfile @relation(fields: [supplierId], references: [id])
|
||||||
|
|
||||||
|
status ConnectionStatus @default(PENDING)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([storeId, supplierId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Invitation {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
token String @unique
|
||||||
|
storeId String
|
||||||
|
store Store @relation(fields: [storeId], references: [id])
|
||||||
|
supplierId String
|
||||||
|
supplier SupplierProfile @relation(fields: [supplierId], references: [id])
|
||||||
|
|
||||||
|
email String // Supplier email to send invitation to
|
||||||
|
expiresAt DateTime
|
||||||
|
used Boolean @default(false)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
usedAt DateTime?
|
||||||
|
|
||||||
|
@@index([token])
|
||||||
|
@@index([supplierId])
|
||||||
|
@@index([storeId])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ConnectionStatus {
|
||||||
|
PENDING
|
||||||
|
CONNECTED
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
model Product {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
supplierId String
|
||||||
|
supplier SupplierProfile @relation(fields: [supplierId], references: [id])
|
||||||
|
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
price Float
|
||||||
|
sku String?
|
||||||
|
imageUrl String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ProductSyncStatus {
|
||||||
|
PENDING
|
||||||
|
ACCEPTED
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProductSync {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
storeId String
|
||||||
|
store Store @relation(fields: [storeId], references: [id])
|
||||||
|
supplierId String
|
||||||
|
supplier SupplierProfile @relation(fields: [supplierId], references: [id])
|
||||||
|
|
||||||
|
shopifyProductId String // Shopify product ID from vendor's store
|
||||||
|
shopifyProductTitle String
|
||||||
|
shopifyProductData Json? // Store full product data for reference
|
||||||
|
|
||||||
|
status ProductSyncStatus @default(PENDING)
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
requestedBy String // userId of vendor who requested
|
||||||
|
acceptedAt DateTime?
|
||||||
|
rejectedAt DateTime?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([storeId, supplierId, shopifyProductId])
|
||||||
|
@@index([storeId])
|
||||||
|
@@index([supplierId])
|
||||||
|
@@index([status])
|
||||||
|
@@index([shopifyProductId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model GmailIntegration {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
accessToken String @db.Text
|
||||||
|
refreshToken String? @db.Text
|
||||||
|
expiresAt DateTime
|
||||||
|
|
||||||
|
email String // Gmail email address
|
||||||
|
scope String // OAuth scopes granted
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NotificationType {
|
||||||
|
INVITATION_ACCEPTED
|
||||||
|
INVITATION_RECEIVED
|
||||||
|
CONNECTION_STATUS_CHANGED
|
||||||
|
ORDER_RECEIVED
|
||||||
|
PRODUCT_UPDATED
|
||||||
|
PRODUCT_SYNC_REQUESTED
|
||||||
|
PRODUCT_SYNC_ACCEPTED
|
||||||
|
PRODUCT_SYNC_REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
model Notification {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
type NotificationType
|
||||||
|
title String
|
||||||
|
message String
|
||||||
|
read Boolean @default(false)
|
||||||
|
readAt DateTime?
|
||||||
|
|
||||||
|
// Optional metadata for linking to related entities
|
||||||
|
metadata Json? // Store related IDs (connectionId, invitationId, etc.)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([userId, read])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model EmailLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
storeId String?
|
||||||
|
store Store? @relation(fields: [storeId], references: [id])
|
||||||
|
|
||||||
|
to String
|
||||||
|
subject String
|
||||||
|
bodyPreview String?
|
||||||
|
|
||||||
|
// Whether this email was triggered automatically by the system
|
||||||
|
isAutomated Boolean @default(false)
|
||||||
|
trigger String? // e.g. "GMAIL_AUTO_REPLY", "TICKET_CREATED"
|
||||||
|
|
||||||
|
status String // "SENT" | "FAILED"
|
||||||
|
providerMessageId String?
|
||||||
|
errorMessage String?
|
||||||
|
|
||||||
|
sentAt DateTime @default(now())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([storeId, sentAt])
|
||||||
|
@@index([userId, sentAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum KnowledgeDocumentStatus {
|
||||||
|
UPLOADED
|
||||||
|
PROCESSING
|
||||||
|
READY
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum KnowledgeChunkSourceType {
|
||||||
|
DOCUMENT
|
||||||
|
FAQ
|
||||||
|
}
|
||||||
|
|
||||||
|
model KnowledgeDocument {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
storeId String
|
||||||
|
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
title String
|
||||||
|
fileName String
|
||||||
|
mimeType String
|
||||||
|
sizeBytes Int
|
||||||
|
status KnowledgeDocumentStatus @default(UPLOADED)
|
||||||
|
errorMessage String?
|
||||||
|
|
||||||
|
// Where the raw file is stored (local path or object storage key)
|
||||||
|
storagePath String
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
chunks KnowledgeChunk[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([storeId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model FaqItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
storeId String
|
||||||
|
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
question String
|
||||||
|
answer String
|
||||||
|
tags Json?
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
chunks KnowledgeChunk[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([storeId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model KnowledgeChunk {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
storeId String
|
||||||
|
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
documentId String?
|
||||||
|
document KnowledgeDocument? @relation(fields: [documentId], references: [id])
|
||||||
|
|
||||||
|
faqItemId String?
|
||||||
|
faqItem FaqItem? @relation(fields: [faqItemId], references: [id])
|
||||||
|
|
||||||
|
sourceType KnowledgeChunkSourceType
|
||||||
|
content String
|
||||||
|
metadata Json?
|
||||||
|
|
||||||
|
// pgvector-backed column; accessed via raw SQL for similarity search
|
||||||
|
embedding Unsupported("vector")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([storeId])
|
||||||
|
@@index([documentId])
|
||||||
|
@@index([faqItemId])
|
||||||
|
}
|
||||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
4
public/icon.svg
Normal file
4
public/icon.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="32" height="32" rx="8" fill="#4F46E5"/>
|
||||||
|
<path d="M16 6L10 18H14L12 26L22 14H18L20 6H16Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 224 B |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
@ -1 +0,0 @@
|
|||||||
TASK_TO_RUN=edit_distance
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
FROM debian:stable-slim
|
|
||||||
|
|
||||||
# Install necessary packages
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
g++ \
|
|
||||||
make \
|
|
||||||
openmpi-bin \
|
|
||||||
libopenmpi-dev \
|
|
||||||
openssh-server && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Set up SSH for MPI
|
|
||||||
RUN mkdir -p /var/run/sshd
|
|
||||||
RUN ssh-keygen -t rsa -f /root/.ssh/id_rsa -N ''
|
|
||||||
RUN cat /root/.ssh/id_rsa.pub >> /root/.ssh/authorized_keys
|
|
||||||
RUN chmod 600 /root/.ssh/authorized_keys
|
|
||||||
RUN echo "Host *\n\tStrictHostKeyChecking no" >> /root/.ssh/config
|
|
||||||
RUN sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy source files
|
|
||||||
COPY . /app/
|
|
||||||
|
|
||||||
# Compile the MPI program
|
|
||||||
RUN mpic++ -O2 -std=c++17 -o edit_distance edit_distance_mpi.cpp
|
|
||||||
RUN chmod +x /app/edit_distance || true
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
# MPI Edit Distance Implementation
|
|
||||||
|
|
||||||
This project implements the **Edit Distance (Needleman-Wunsch)** algorithm using MPI with three different communication strategies:
|
|
||||||
|
|
||||||
- **Point-to-Point Communication** (MPI_Send/MPI_Recv)
|
|
||||||
- **Scatter + Gather Communication** (MPI_Scatter/Gather)
|
|
||||||
- **Gather-based Multiple Pair Computation** (MPI_Gather)
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Option 1: Single Container (Recommended - Works Perfectly)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build the project
|
|
||||||
docker compose build
|
|
||||||
|
|
||||||
# Method 1: Use default sequences (ACGTAG vs ACTG)
|
|
||||||
docker run --rm -it pdc-poc-master bash -c "cd /app && mpirun -np 2 --allow-run-as-root ./edit_distance"
|
|
||||||
|
|
||||||
# Method 2: Provide custom sequences as command line arguments
|
|
||||||
docker run --rm -it pdc-poc-master bash -c "cd /app && mpirun -np 2 --allow-run-as-root ./edit_distance HELLO WORLD"
|
|
||||||
|
|
||||||
# Method 3: Interactive input (single process only)
|
|
||||||
docker run --rm -it pdc-poc-master bash -c "cd /app && mpirun -np 1 --allow-run-as-root ./edit_distance -i"
|
|
||||||
|
|
||||||
# Run with different process counts
|
|
||||||
docker run --rm -it pdc-poc-master bash -c "cd /app && mpirun -np 4 --allow-run-as-root ./edit_distance"
|
|
||||||
docker run --rm -it pdc-poc-master bash -c "cd /app && mpirun -np 8 --allow-run-as-root ./edit_distance"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option 2: Multi-Container Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build the project
|
|
||||||
docker compose build
|
|
||||||
|
|
||||||
# Run with 2 processes (1 master + 1 worker)
|
|
||||||
docker compose up --scale worker1=1 worker2=0 worker3=0 worker4=0
|
|
||||||
|
|
||||||
# Run with 4 processes (1 master + 3 workers)
|
|
||||||
docker compose up --scale worker1=1 worker2=1 worker3=1 worker4=0
|
|
||||||
|
|
||||||
# Run with 5 processes (1 master + 4 workers)
|
|
||||||
docker compose up --scale worker1=1 worker2=1 worker3=1 worker4=1
|
|
||||||
|
|
||||||
# View logs to see results
|
|
||||||
docker compose logs
|
|
||||||
|
|
||||||
# Shut down containers
|
|
||||||
docker compose down
|
|
||||||
```
|
|
||||||
|
|
||||||
## Expected Output
|
|
||||||
|
|
||||||
```
|
|
||||||
Sequence 1: ACGTAG
|
|
||||||
Sequence 2: ACTG
|
|
||||||
|
|
||||||
Edit Distance (P2P) = 2, Time = 0.000s
|
|
||||||
Edit Distance (Scatter) = 2, Time = 0.000s
|
|
||||||
Edit Distance (Gather) = [2, 3, 3], Time = 0.000s
|
|
||||||
```
|
|
||||||
|
|
||||||
## Input Methods
|
|
||||||
|
|
||||||
1. **Default Sequences**: No arguments - uses "ACGTAG" vs "ACTG"
|
|
||||||
2. **Command Line**: `./edit_distance SEQUENCE1 SEQUENCE2`
|
|
||||||
3. **Interactive**: `./edit_distance -i` (single process only)
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
- **SSH errors**: Use Option 1 (single container) for reliable execution
|
|
||||||
- **Permission errors**: Ensure Docker has proper permissions
|
|
||||||
- **Build errors**: Check that all files are in the correct directory
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
- `edit_distance_mpi.cpp` - Main MPI implementation
|
|
||||||
- `Dockerfile` - Container configuration
|
|
||||||
- `docker-compose.yml` - Multi-container orchestration
|
|
||||||
- `hostfile` - MPI host configuration
|
|
||||||
- `README.md` - This file
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
master:
|
|
||||||
build: .
|
|
||||||
container_name: pdc-master
|
|
||||||
hostname: master
|
|
||||||
volumes:
|
|
||||||
- ./hostfile:/app/hostfile
|
|
||||||
environment:
|
|
||||||
- FRIENDLY_NAME=pdc-master
|
|
||||||
# This line automatically reads from the .env file
|
|
||||||
command: >
|
|
||||||
sh -c "/usr/sbin/sshd & sleep 5 && mpirun -np 5 --hostfile /app/hostfile --allow-run-as-root /app/${TASK_TO_RUN:-edit_distance}"
|
|
||||||
networks:
|
|
||||||
- pdcn
|
|
||||||
|
|
||||||
worker1:
|
|
||||||
build: .
|
|
||||||
container_name: pdc-worker1
|
|
||||||
hostname: worker1
|
|
||||||
environment:
|
|
||||||
- FRIENDLY_NAME=pdc-worker1
|
|
||||||
command: /usr/sbin/sshd -D
|
|
||||||
networks:
|
|
||||||
- pdcn
|
|
||||||
|
|
||||||
worker2:
|
|
||||||
build: .
|
|
||||||
container_name: pdc-worker2
|
|
||||||
hostname: worker2
|
|
||||||
environment:
|
|
||||||
- FRIENDLY_NAME=pdc-worker2
|
|
||||||
command: /usr/sbin/sshd -D
|
|
||||||
networks:
|
|
||||||
- pdcn
|
|
||||||
|
|
||||||
worker3:
|
|
||||||
build: .
|
|
||||||
container_name: pdc-worker3
|
|
||||||
hostname: worker3
|
|
||||||
environment:
|
|
||||||
- FRIENDLY_NAME=pdc-worker3
|
|
||||||
command: /usr/sbin/sshd -D
|
|
||||||
networks:
|
|
||||||
- pdcn
|
|
||||||
|
|
||||||
worker4:
|
|
||||||
build: .
|
|
||||||
container_name: pdc-worker4
|
|
||||||
hostname: worker4
|
|
||||||
environment:
|
|
||||||
- FRIENDLY_NAME=pdc-worker4
|
|
||||||
command: /usr/sbin/sshd -D
|
|
||||||
networks:
|
|
||||||
- pdcn
|
|
||||||
|
|
||||||
networks:
|
|
||||||
pdcn:
|
|
||||||
driver: bridge
|
|
||||||
@ -1,230 +0,0 @@
|
|||||||
#include <mpi.h>
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
#include <iostream>
|
|
||||||
#include <chrono>
|
|
||||||
#include <algorithm>
|
|
||||||
#include <cstring>
|
|
||||||
#include <iomanip>
|
|
||||||
|
|
||||||
using namespace std;
|
|
||||||
|
|
||||||
int min3(int a, int b, int c) {
|
|
||||||
return min(a, min(b, c));
|
|
||||||
}
|
|
||||||
|
|
||||||
int compute_edit_distance(const string& seq1, const string& seq2) {
|
|
||||||
int m = seq1.length();
|
|
||||||
int n = seq2.length();
|
|
||||||
|
|
||||||
vector<vector<int>> dp(m + 1, vector<int>(n + 1));
|
|
||||||
|
|
||||||
// Initialize base cases
|
|
||||||
for (int i = 0; i <= m; i++) {
|
|
||||||
dp[i][0] = i;
|
|
||||||
}
|
|
||||||
for (int j = 0; j <= n; j++) {
|
|
||||||
dp[0][j] = j;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fill the DP table
|
|
||||||
for (int i = 1; i <= m; i++) {
|
|
||||||
for (int j = 1; j <= n; j++) {
|
|
||||||
if (seq1[i-1] == seq2[j-1]) {
|
|
||||||
dp[i][j] = dp[i-1][j-1];
|
|
||||||
} else {
|
|
||||||
dp[i][j] = min3(
|
|
||||||
dp[i-1][j] + 1, // deletion
|
|
||||||
dp[i][j-1] + 1, // insertion
|
|
||||||
dp[i-1][j-1] + 1 // substitution
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return dp[m][n];
|
|
||||||
}
|
|
||||||
|
|
||||||
int p2p_edit_distance(const string& seq1, const string& seq2, int rank, int size) {
|
|
||||||
double start_time, end_time;
|
|
||||||
|
|
||||||
if (rank == 0) {
|
|
||||||
start_time = MPI_Wtime();
|
|
||||||
}
|
|
||||||
|
|
||||||
int result_p2p;
|
|
||||||
|
|
||||||
if (size == 1) {
|
|
||||||
result_p2p = compute_edit_distance(seq1, seq2);
|
|
||||||
} else {
|
|
||||||
// Simplified P2P: Each process computes independently, then gather results
|
|
||||||
result_p2p = compute_edit_distance(seq1, seq2);
|
|
||||||
|
|
||||||
// Gather results using point-to-point communication
|
|
||||||
if (rank == 0) {
|
|
||||||
vector<int> all_results(size);
|
|
||||||
all_results[0] = result_p2p;
|
|
||||||
|
|
||||||
// Receive results from other processes
|
|
||||||
for (int proc = 1; proc < size; proc++) {
|
|
||||||
MPI_Recv(&all_results[proc], 1, MPI_INT, proc, 0, MPI_COMM_WORLD, MPI_STATUS_IGNORE);
|
|
||||||
}
|
|
||||||
|
|
||||||
result_p2p = all_results[0]; // Use first result
|
|
||||||
} else {
|
|
||||||
// Send result to process 0
|
|
||||||
MPI_Send(&result_p2p, 1, MPI_INT, 0, 0, MPI_COMM_WORLD);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rank == 0) {
|
|
||||||
end_time = MPI_Wtime();
|
|
||||||
cout << "Edit Distance (P2P) = " << result_p2p << ", Time = " << fixed << setprecision(6) << (end_time - start_time) << "s" << endl;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result_p2p;
|
|
||||||
}
|
|
||||||
|
|
||||||
int scatter_gather_edit_distance(const string& seq1, const string& seq2, int rank, int size) {
|
|
||||||
double start_time, end_time;
|
|
||||||
|
|
||||||
if (rank == 0) {
|
|
||||||
start_time = MPI_Wtime();
|
|
||||||
}
|
|
||||||
|
|
||||||
int result_scatter;
|
|
||||||
|
|
||||||
if (size == 1) {
|
|
||||||
result_scatter = compute_edit_distance(seq1, seq2);
|
|
||||||
} else {
|
|
||||||
// Each process computes the full matrix independently
|
|
||||||
result_scatter = compute_edit_distance(seq1, seq2);
|
|
||||||
|
|
||||||
// Gather results (though they should be the same)
|
|
||||||
vector<int> all_results(size);
|
|
||||||
MPI_Gather(&result_scatter, 1, MPI_INT, all_results.data(), 1, MPI_INT, 0, MPI_COMM_WORLD);
|
|
||||||
|
|
||||||
if (rank == 0) {
|
|
||||||
result_scatter = all_results[0]; // Use first result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rank == 0) {
|
|
||||||
end_time = MPI_Wtime();
|
|
||||||
cout << "Edit Distance (Scatter) = " << result_scatter << ", Time = " << fixed << setprecision(6) << (end_time - start_time) << "s" << endl;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result_scatter;
|
|
||||||
}
|
|
||||||
|
|
||||||
void gather_multiple_pairs(const vector<pair<string,string>>& sequence_pairs, int rank, int size, vector<int>& results) {
|
|
||||||
double start_time, end_time;
|
|
||||||
|
|
||||||
if (rank == 0) {
|
|
||||||
start_time = MPI_Wtime();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Distribute pairs among processes
|
|
||||||
int pairs_per_proc = max(1, (int)sequence_pairs.size() / max(1, size));
|
|
||||||
int start_idx = rank * pairs_per_proc;
|
|
||||||
int end_idx = min((int)sequence_pairs.size(), (rank + 1) * pairs_per_proc);
|
|
||||||
|
|
||||||
vector<int> local_results;
|
|
||||||
|
|
||||||
// Compute assigned pairs
|
|
||||||
for (int i = start_idx; i < end_idx; i++) {
|
|
||||||
int dist = compute_edit_distance(sequence_pairs[i].first, sequence_pairs[i].second);
|
|
||||||
local_results.push_back(dist);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gather results using MPI_Gatherv
|
|
||||||
vector<int> all_results;
|
|
||||||
vector<int> all_sizes(size);
|
|
||||||
vector<int> all_displs(size);
|
|
||||||
|
|
||||||
// First gather the sizes
|
|
||||||
int local_size = local_results.size();
|
|
||||||
MPI_Gather(&local_size, 1, MPI_INT, all_sizes.data(), 1, MPI_INT, 0, MPI_COMM_WORLD);
|
|
||||||
|
|
||||||
if (rank == 0) {
|
|
||||||
// Calculate displacements
|
|
||||||
int total_size = 0;
|
|
||||||
for (int i = 0; i < size; i++) {
|
|
||||||
all_displs[i] = total_size;
|
|
||||||
total_size += all_sizes[i];
|
|
||||||
}
|
|
||||||
all_results.resize(total_size);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gather the actual data
|
|
||||||
MPI_Gatherv(local_results.data(), local_size, MPI_INT,
|
|
||||||
all_results.data(), all_sizes.data(), all_displs.data(), MPI_INT,
|
|
||||||
0, MPI_COMM_WORLD);
|
|
||||||
|
|
||||||
if (rank == 0) {
|
|
||||||
results = all_results;
|
|
||||||
end_time = MPI_Wtime();
|
|
||||||
cout << "Edit Distance (Gather) = [";
|
|
||||||
for (size_t i = 0; i < results.size(); i++) {
|
|
||||||
cout << results[i];
|
|
||||||
if (i < results.size() - 1) cout << ", ";
|
|
||||||
}
|
|
||||||
cout << "], Time = " << fixed << setprecision(6) << (end_time - start_time) << "s" << endl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int main(int argc, char** argv) {
|
|
||||||
MPI_Init(&argc, &argv);
|
|
||||||
int rank, size;
|
|
||||||
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
|
|
||||||
MPI_Comm_size(MPI_COMM_WORLD, &size);
|
|
||||||
|
|
||||||
string seq1, seq2;
|
|
||||||
|
|
||||||
// Get input from command line arguments or use defaults
|
|
||||||
if (argc >= 3) {
|
|
||||||
seq1 = argv[1];
|
|
||||||
seq2 = argv[2];
|
|
||||||
} else if (argc == 2 && string(argv[1]) == "-i") {
|
|
||||||
// Interactive input mode (only for single process)
|
|
||||||
if (rank == 0 && size == 1) {
|
|
||||||
cout << "Enter Sequence 1: ";
|
|
||||||
getline(cin, seq1);
|
|
||||||
cout << "Enter Sequence 2: ";
|
|
||||||
getline(cin, seq2);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Default sequences if no arguments provided
|
|
||||||
seq1 = "ACGTAG";
|
|
||||||
seq2 = "ACTG";
|
|
||||||
}
|
|
||||||
|
|
||||||
vector<pair<string,string>> sequence_pairs = {
|
|
||||||
{seq1, seq2},
|
|
||||||
{"CGTAG", "ATG"},
|
|
||||||
{"AGTAG", "ACT"}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add longer sequences for better timing measurements
|
|
||||||
if (seq1.length() < 10) {
|
|
||||||
sequence_pairs.push_back({"ACGTAGCTAGCTAGCTAG", "ACTGCTAGCTAGCTAG"});
|
|
||||||
sequence_pairs.push_back({"HELLOWORLD", "WORLDHELLO"});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display input sequences
|
|
||||||
if (rank == 0) {
|
|
||||||
cout << "Sequence 1: " << seq1 << endl;
|
|
||||||
cout << "Sequence 2: " << seq2 << endl;
|
|
||||||
cout << endl;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run all three scenarios
|
|
||||||
p2p_edit_distance(seq1, seq2, rank, size);
|
|
||||||
scatter_gather_edit_distance(seq1, seq2, rank, size);
|
|
||||||
|
|
||||||
vector<int> gather_results;
|
|
||||||
gather_multiple_pairs(sequence_pairs, rank, size, gather_results);
|
|
||||||
|
|
||||||
MPI_Finalize();
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
master slots=1
|
|
||||||
worker1 slots=1
|
|
||||||
worker2 slots=1
|
|
||||||
worker3 slots=1
|
|
||||||
worker4 slots=1
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
# Problem Statement: MPI Edit Distance Implementation
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
Implement the Edit Distance (Needleman-Wunsch global alignment) algorithm using MPI with three different communication strategies and compare their performance.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### 1. Algorithm Implementation
|
|
||||||
- Implement the Needleman-Wunsch algorithm for global sequence alignment
|
|
||||||
- Calculate edit distance between two DNA/protein sequences
|
|
||||||
- Use dynamic programming approach
|
|
||||||
|
|
||||||
### 2. MPI Communication Strategies
|
|
||||||
|
|
||||||
#### Strategy 1: Point-to-Point Communication
|
|
||||||
- Use `MPI_Send` and `MPI_Recv` for data distribution
|
|
||||||
- Distribute computation across multiple processes
|
|
||||||
- Collect results using point-to-point communication
|
|
||||||
|
|
||||||
#### Strategy 2: Scatter + Gather Communication
|
|
||||||
- Use `MPI_Scatter` to distribute data
|
|
||||||
- Use `MPI_Gather` to collect results
|
|
||||||
- Implement collective communication patterns
|
|
||||||
|
|
||||||
#### Strategy 3: Gather-based Multiple Pair Computation
|
|
||||||
- Process multiple sequence pairs in parallel
|
|
||||||
- Use `MPI_Gather` to collect results from all processes
|
|
||||||
- Implement load balancing across processes
|
|
||||||
|
|
||||||
### 3. Input/Output Format
|
|
||||||
|
|
||||||
**Input:**
|
|
||||||
```
|
|
||||||
Sequence 1: ACGTAG
|
|
||||||
Sequence 2: ACTG
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output:**
|
|
||||||
```
|
|
||||||
Edit Distance (P2P) = 2, Time = 0.000s
|
|
||||||
Edit Distance (Scatter) = 2, Time = 0.000s
|
|
||||||
Edit Distance (Gather) = [2, 3, 3], Time = 0.000s
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Performance Requirements
|
|
||||||
- Measure execution time for each strategy
|
|
||||||
- Support different numbers of MPI processes (2, 4, 8)
|
|
||||||
- Compare performance across communication strategies
|
|
||||||
|
|
||||||
### 5. Docker Environment
|
|
||||||
- Containerize the application
|
|
||||||
- Support both single-container and multi-container execution
|
|
||||||
- Provide easy setup and execution instructions
|
|
||||||
|
|
||||||
## Expected Results
|
|
||||||
- Correct edit distance calculations for all strategies
|
|
||||||
- Performance comparison showing time differences
|
|
||||||
- Scalable implementation that works with different process counts
|
|
||||||
- Clean, readable output matching the specified format
|
|
||||||
66
scripts/fix-domains.ts
Normal file
66
scripts/fix-domains.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Script to fix domains with whitespace issues in the database
|
||||||
|
* Run with: npx tsx scripts/fix-domains.ts
|
||||||
|
*
|
||||||
|
* Make sure DATABASE_URL is set in your environment or .env file
|
||||||
|
*/
|
||||||
|
|
||||||
|
import "dotenv/config"
|
||||||
|
import pg from "pg"
|
||||||
|
import { PrismaPg } from "@prisma/adapter-pg"
|
||||||
|
import { PrismaClient } from "@prisma/client"
|
||||||
|
import { PrismaClientOptions } from "@prisma/client/runtime/client"
|
||||||
|
|
||||||
|
// Use the same setup as the main app
|
||||||
|
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })
|
||||||
|
const adapter = new PrismaPg(pool)
|
||||||
|
const prisma = new PrismaClient({
|
||||||
|
adapter: adapter as unknown as PrismaClientOptions["adapter"]
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fixStoreDomains() {
|
||||||
|
try {
|
||||||
|
const stores = await prisma.store.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
shopifyDomain: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
let fixed = 0
|
||||||
|
for (const store of stores) {
|
||||||
|
if (!store.shopifyDomain) continue
|
||||||
|
|
||||||
|
// Clean domain: remove protocol, trailing slashes, and trim whitespace
|
||||||
|
const cleanDomain = store.shopifyDomain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim()
|
||||||
|
|
||||||
|
// Only update if domain changed
|
||||||
|
if (cleanDomain !== store.shopifyDomain) {
|
||||||
|
await prisma.store.update({
|
||||||
|
where: { id: store.id },
|
||||||
|
data: { shopifyDomain: cleanDomain },
|
||||||
|
})
|
||||||
|
console.log(`Fixed domain for store ${store.id}: "${store.shopifyDomain}" -> "${cleanDomain}"`)
|
||||||
|
fixed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ Fixed ${fixed} store domain(s) out of ${stores.length} total`)
|
||||||
|
return { fixed, total: stores.length }
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Error fixing domains:", error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect()
|
||||||
|
await pool.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fixStoreDomains()
|
||||||
|
.then(() => {
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
138
src/app/admin/connections/page.tsx
Normal file
138
src/app/admin/connections/page.tsx
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { auth } from "@/auth"
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { Link2 } from "lucide-react"
|
||||||
|
|
||||||
|
interface AdminConnection {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
store: {
|
||||||
|
businessName: string
|
||||||
|
shopifyStoreName: string
|
||||||
|
}
|
||||||
|
supplier: {
|
||||||
|
companyName: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getConnections(): Promise<AdminConnection[]> {
|
||||||
|
const res = await fetch(`${process.env.NEXTAUTH_URL}/api/admin/connections`, {
|
||||||
|
cache: "no-store",
|
||||||
|
})
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = (await res.json()) as { connections: AdminConnection[] }
|
||||||
|
return data.connections
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminConnectionsPage() {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session || session.user.role !== "ADMIN") {
|
||||||
|
redirect("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
const connections = await getConnections()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 h-full">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-white tracking-tight">
|
||||||
|
Connections
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-400 mt-1 max-w-2xl">
|
||||||
|
Read-only view of vendor ↔ supplier connections across the platform.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-5 overflow-auto">
|
||||||
|
<table className="min-w-full text-sm text-left">
|
||||||
|
<thead className="border-b border-white/10 text-xs uppercase tracking-wide text-slate-400">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Vendor Store</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Supplier</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Status</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Created</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Last Updated</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">
|
||||||
|
{connections.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={5}
|
||||||
|
className="py-6 text-center text-sm text-slate-500"
|
||||||
|
>
|
||||||
|
No connections found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{connections.map((connection) => (
|
||||||
|
<tr key={connection.id} className="hover:bg-white/5">
|
||||||
|
<td className="py-3 pr-4 align-top">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blue-500/20 border border-blue-400/30 flex items-center justify-center">
|
||||||
|
<Link2 className="w-4 h-4 text-blue-200" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-white">
|
||||||
|
{connection.store.businessName}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400">
|
||||||
|
{connection.store.shopifyStoreName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 align-top">
|
||||||
|
<div className="text-xs font-semibold text-white">
|
||||||
|
{connection.supplier.companyName}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 align-top">
|
||||||
|
<StatusPill status={connection.status} />
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 align-top text-xs text-slate-400">
|
||||||
|
{new Date(connection.createdAt).toLocaleString("en-US", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 align-top text-xs text-slate-400">
|
||||||
|
{new Date(connection.updatedAt).toLocaleString("en-US", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: string }) {
|
||||||
|
const normalized = status.toUpperCase()
|
||||||
|
let color =
|
||||||
|
"bg-slate-800/60 text-slate-200 border-slate-500/40"
|
||||||
|
|
||||||
|
if (normalized === "CONNECTED") {
|
||||||
|
color = "bg-emerald-500/15 text-emerald-200 border-emerald-500/40"
|
||||||
|
} else if (normalized === "PENDING") {
|
||||||
|
color = "bg-amber-500/15 text-amber-200 border-amber-500/40"
|
||||||
|
} else if (normalized === "REJECTED") {
|
||||||
|
color = "bg-rose-500/15 text-rose-200 border-rose-500/40"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide border ${color}`}
|
||||||
|
>
|
||||||
|
{normalized}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
204
src/app/admin/emails/page.tsx
Normal file
204
src/app/admin/emails/page.tsx
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import { auth } from "@/auth"
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { Mail } from "lucide-react"
|
||||||
|
|
||||||
|
interface AdminEmailLog {
|
||||||
|
id: string
|
||||||
|
to: string
|
||||||
|
subject: string
|
||||||
|
bodyPreview: string | null
|
||||||
|
isAutomated: boolean
|
||||||
|
trigger: string | null
|
||||||
|
status: string
|
||||||
|
providerMessageId: string | null
|
||||||
|
errorMessage: string | null
|
||||||
|
sentAt: string
|
||||||
|
store: {
|
||||||
|
businessName: string
|
||||||
|
shopifyStoreName: string
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEmails(): Promise<{ emails: AdminEmailLog[]; total: number }> {
|
||||||
|
// Use a relative URL so the admin's session cookies are forwarded
|
||||||
|
const res = await fetch("/api/admin/emails?limit=100", {
|
||||||
|
cache: "no-store",
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
return { emails: [], total: 0 }
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { emails: AdminEmailLog[]; total: number }
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminEmailsPage() {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session || session.user.role !== "ADMIN") {
|
||||||
|
redirect("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { emails, total } = await getEmails()
|
||||||
|
const failedCount = emails.filter((e) => e.status === "FAILED").length
|
||||||
|
const automatedCount = emails.filter((e) => e.isAutomated).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 h-full">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-white tracking-tight">
|
||||||
|
Automated Emails
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-400 mt-1 max-w-2xl">
|
||||||
|
Read-only visibility into automated and system-triggered emails sent
|
||||||
|
from vendor Gmail integrations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
|
Total Logged Emails
|
||||||
|
</p>
|
||||||
|
<p className="text-3xl font-bold text-white mt-2">{total}</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
Across all vendors and stores
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-11 h-11 rounded-2xl bg-indigo-500/30 border border-indigo-400/40 flex items-center justify-center">
|
||||||
|
<Mail className="w-6 h-6 text-indigo-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
|
Automated Emails
|
||||||
|
</p>
|
||||||
|
<p className="text-3xl font-bold text-white mt-2">{automatedCount}</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
Marked as system-triggered
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-11 h-11 rounded-2xl bg-emerald-500/30 border border-emerald-400/40 flex items-center justify-center">
|
||||||
|
<Mail className="w-6 h-6 text-emerald-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
|
Failed Sends
|
||||||
|
</p>
|
||||||
|
<p className="text-3xl font-bold text-white mt-2">{failedCount}</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
Emails that failed to send via Gmail API
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-11 h-11 rounded-2xl bg-rose-500/30 border border-rose-400/40 flex items-center justify-center">
|
||||||
|
<Mail className="w-6 h-6 text-rose-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-5 overflow-auto">
|
||||||
|
<table className="min-w-full text-sm text-left">
|
||||||
|
<thead className="border-b border-white/10 text-xs uppercase tracking-wide text-slate-400">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Vendor Store</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Recipient</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Subject</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Trigger</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Status</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Sent At</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">
|
||||||
|
{emails.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={6}
|
||||||
|
className="py-6 text-center text-sm text-slate-500"
|
||||||
|
>
|
||||||
|
No emails logged yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{emails.map((email) => (
|
||||||
|
<tr key={email.id} className="hover:bg-white/5 align-top">
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
{email.store ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-white">
|
||||||
|
{email.store.businessName}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400">
|
||||||
|
{email.store.shopifyStoreName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
Unscoped / System
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 text-xs text-slate-200">
|
||||||
|
{email.to}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<div className="text-xs font-semibold text-white line-clamp-1">
|
||||||
|
{email.subject}
|
||||||
|
</div>
|
||||||
|
{email.bodyPreview && (
|
||||||
|
<div className="text-[11px] text-slate-400 line-clamp-2 mt-0.5">
|
||||||
|
{email.bodyPreview}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 text-[11px] text-slate-300">
|
||||||
|
{email.isAutomated
|
||||||
|
? email.trigger || "AUTOMATED"
|
||||||
|
: email.trigger || "MANUAL"}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<StatusPill status={email.status} />
|
||||||
|
{email.errorMessage && (
|
||||||
|
<div className="mt-1 text-[10px] text-rose-300 line-clamp-1">
|
||||||
|
{email.errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 text-xs text-slate-400">
|
||||||
|
{new Date(email.sentAt).toLocaleString("en-US", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: string }) {
|
||||||
|
const normalized = status.toUpperCase()
|
||||||
|
let color =
|
||||||
|
"bg-slate-800/60 text-slate-200 border-slate-500/40"
|
||||||
|
|
||||||
|
if (normalized === "SENT") {
|
||||||
|
color = "bg-emerald-500/15 text-emerald-200 border-emerald-500/40"
|
||||||
|
} else if (normalized === "FAILED") {
|
||||||
|
color = "bg-rose-500/15 text-rose-200 border-rose-500/40"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide border ${color}`}
|
||||||
|
>
|
||||||
|
{normalized}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
252
src/app/admin/events/page.tsx
Normal file
252
src/app/admin/events/page.tsx
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
import { auth } from "@/auth"
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { Activity, Link2, Radar, Shuffle } from "lucide-react"
|
||||||
|
|
||||||
|
type AdminEventType = "CONNECTION" | "PRODUCT_SYNC" | "SYNC_HEALTH"
|
||||||
|
|
||||||
|
interface AdminEvent {
|
||||||
|
id: string
|
||||||
|
type: AdminEventType
|
||||||
|
status: string
|
||||||
|
timestamp: string
|
||||||
|
storeName: string | null
|
||||||
|
supplierName: string | null
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEvents(type?: AdminEventType): Promise<AdminEvent[]> {
|
||||||
|
const searchParams = new URLSearchParams()
|
||||||
|
searchParams.set("limit", "50")
|
||||||
|
if (type) {
|
||||||
|
searchParams.set("type", type)
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.NEXTAUTH_URL}/api/admin/events?${searchParams.toString()}`,
|
||||||
|
{
|
||||||
|
cache: "no-store",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as { events: AdminEvent[] }
|
||||||
|
return data.events
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeBadge(eventType: AdminEventType) {
|
||||||
|
switch (eventType) {
|
||||||
|
case "CONNECTION":
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/15 text-blue-200 border border-blue-500/40 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide">
|
||||||
|
<Link2 className="w-3 h-3" />
|
||||||
|
Connection
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
case "PRODUCT_SYNC":
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-purple-500/15 text-purple-200 border border-purple-500/40 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide">
|
||||||
|
<Shuffle className="w-3 h-3" />
|
||||||
|
Product Sync
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
case "SYNC_HEALTH":
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 text-emerald-200 border border-emerald-500/40 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide">
|
||||||
|
<Radar className="w-3 h-3" />
|
||||||
|
Sync Health
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadge(status: string) {
|
||||||
|
const normalized = status.toUpperCase()
|
||||||
|
let colorClasses =
|
||||||
|
"bg-slate-800/60 text-slate-200 border-slate-500/40"
|
||||||
|
|
||||||
|
if (normalized === "CONNECTED" || normalized === "ACCEPTED" || normalized === "SUCCESS") {
|
||||||
|
colorClasses = "bg-emerald-500/15 text-emerald-200 border-emerald-500/40"
|
||||||
|
} else if (normalized === "PENDING" || normalized === "IN_PROGRESS") {
|
||||||
|
colorClasses = "bg-amber-500/15 text-amber-200 border-amber-500/40"
|
||||||
|
} else if (normalized === "REJECTED" || normalized === "FAILED") {
|
||||||
|
colorClasses = "bg-rose-500/15 text-rose-200 border-rose-500/40"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide border ${colorClasses}`}
|
||||||
|
>
|
||||||
|
{normalized}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminEventsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ type?: string }>
|
||||||
|
}) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session || session.user.role !== "ADMIN") {
|
||||||
|
redirect("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type } = await searchParams
|
||||||
|
const filterType =
|
||||||
|
type && ["CONNECTION", "PRODUCT_SYNC", "SYNC_HEALTH"].includes(type)
|
||||||
|
? (type as AdminEventType)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const events = await getEvents(filterType)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 h-full">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-white tracking-tight">
|
||||||
|
Event Timeline
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-400 mt-1 max-w-2xl">
|
||||||
|
Chronological, read-only view of platform activity across connections,
|
||||||
|
product syncs, and sync health events.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-4 flex flex-wrap gap-2">
|
||||||
|
<span className="text-[11px] uppercase tracking-wide text-slate-400 font-semibold mr-2">
|
||||||
|
Filter by type:
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<FilterChip label="All" href="/admin/events" active={!filterType} />
|
||||||
|
<FilterChip
|
||||||
|
label="Connections"
|
||||||
|
href="/admin/events?type=CONNECTION"
|
||||||
|
active={filterType === "CONNECTION"}
|
||||||
|
/>
|
||||||
|
<FilterChip
|
||||||
|
label="Product Syncs"
|
||||||
|
href="/admin/events?type=PRODUCT_SYNC"
|
||||||
|
active={filterType === "PRODUCT_SYNC"}
|
||||||
|
/>
|
||||||
|
<FilterChip
|
||||||
|
label="Sync Health"
|
||||||
|
href="/admin/events?type=SYNC_HEALTH"
|
||||||
|
active={filterType === "SYNC_HEALTH"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-5 overflow-y-auto">
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
No events found for the current filter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute left-4 top-0 bottom-0 w-px bg-white/10" />
|
||||||
|
<div className="space-y-4">
|
||||||
|
{events.map((event) => (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
className="relative pl-10 pr-2 py-2 group"
|
||||||
|
>
|
||||||
|
<div className="absolute left-[14px] top-3 w-3 h-3 rounded-full bg-indigo-400 border-2 border-slate-900 shadow-md shadow-indigo-500/40" />
|
||||||
|
<div className="rounded-xl border border-white/10 bg-slate-900/60 px-4 py-3 flex flex-col gap-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{typeBadge(event.type)}
|
||||||
|
{statusBadge(event.status)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-[11px] text-slate-400">
|
||||||
|
<Activity className="w-3 h-3" />
|
||||||
|
<span>
|
||||||
|
{new Date(
|
||||||
|
event.timestamp,
|
||||||
|
).toLocaleString("en-US", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-slate-200">
|
||||||
|
{event.storeName && (
|
||||||
|
<span className="font-semibold">
|
||||||
|
{event.storeName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{event.storeName && event.supplierName && (
|
||||||
|
<span className="mx-1 text-slate-500">↔</span>
|
||||||
|
)}
|
||||||
|
{event.supplierName && (
|
||||||
|
<span className="font-semibold">
|
||||||
|
{event.supplierName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!event.storeName && !event.supplierName && (
|
||||||
|
<span className="text-slate-400">
|
||||||
|
Platform-level event
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{event.metadata && event.type === "PRODUCT_SYNC" && (
|
||||||
|
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||||
|
{(event.metadata.productTitle as string) ||
|
||||||
|
"Product sync"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{event.metadata && event.type === "SYNC_HEALTH" && (
|
||||||
|
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||||
|
{String(
|
||||||
|
(event.metadata.resourceType ||
|
||||||
|
"unknown") as string,
|
||||||
|
).toUpperCase()}{" "}
|
||||||
|
· Records:{" "}
|
||||||
|
{String(
|
||||||
|
(event.metadata.recordCount ||
|
||||||
|
0) as number,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterChip({
|
||||||
|
label,
|
||||||
|
href,
|
||||||
|
active,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
href: string
|
||||||
|
active: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors ${
|
||||||
|
active
|
||||||
|
? "border-white/20 bg-white/15 text-white"
|
||||||
|
: "border-white/10 bg-slate-900/40 text-slate-300 hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
76
src/app/admin/layout.tsx
Normal file
76
src/app/admin/layout.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { AdminSidebar } from "@/components/AdminSidebar"
|
||||||
|
import { MobileSidebar } from "@/components/MobileSidebar"
|
||||||
|
import UserAccountMenu from "@/components/UserAccountMenu"
|
||||||
|
import NotificationBell from "@/components/NotificationBell"
|
||||||
|
import PageTransition from "@/components/PageTransition"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
|
export default async function AdminLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user.role !== "ADMIN") {
|
||||||
|
// Redirect vendors and suppliers back to their respective dashboards
|
||||||
|
if (session.user.role === "VENDOR") {
|
||||||
|
redirect("/dashboard")
|
||||||
|
}
|
||||||
|
if (session.user.role === "SUPPLIER") {
|
||||||
|
redirect("/supplier")
|
||||||
|
}
|
||||||
|
redirect("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-full relative font-sans dashboard-glass-dark overflow-x-hidden">
|
||||||
|
{/* Desktop Sidebar */}
|
||||||
|
<div className="hidden h-full md:flex md:w-72 md:flex-col md:fixed md:inset-y-0 z-[80] bg-slate-900/70 border-r border-white/10 backdrop-blur-xl">
|
||||||
|
<AdminSidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<main className="md:pl-72 flex flex-col flex-1 h-full min-w-0">
|
||||||
|
{/* Fixed Header */}
|
||||||
|
<header className="h-16 px-4 md:px-8 border-b border-white/10 bg-slate-900/50 backdrop-blur-xl flex items-center justify-between flex-shrink-0 z-50">
|
||||||
|
<div className="flex items-center gap-2 md:gap-4 flex-1 min-w-0">
|
||||||
|
{/* Reuse vendor mobile sidebar trigger for consistency */}
|
||||||
|
<MobileSidebar />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<h2 className="text-xs md:text-sm font-semibold text-slate-300 uppercase tracking-wider">
|
||||||
|
Admin Portal
|
||||||
|
</h2>
|
||||||
|
<span className="text-[10px] text-slate-500">
|
||||||
|
Read-only observability for Shopify, vendors & suppliers
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 md:gap-4 shrink-0">
|
||||||
|
<NotificationBell />
|
||||||
|
<div className="hidden sm:flex flex-col text-right">
|
||||||
|
<span className="text-xs font-bold text-white truncate max-w-[140px]">{session.user?.name || "Admin User"}</span>
|
||||||
|
<span className="text-[10px] text-slate-400 truncate max-w-[160px]">{session.user?.email}</span>
|
||||||
|
</div>
|
||||||
|
<UserAccountMenu />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 flex flex-col min-h-0 overflow-x-hidden p-4 md:p-8">
|
||||||
|
<PageTransition>
|
||||||
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</PageTransition>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
125
src/app/admin/orders/page.tsx
Normal file
125
src/app/admin/orders/page.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { auth } from "@/auth"
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { ShoppingCart } from "lucide-react"
|
||||||
|
|
||||||
|
interface AdminOrder {
|
||||||
|
id: string
|
||||||
|
orderNumber: string
|
||||||
|
email: string | null
|
||||||
|
totalPrice: string
|
||||||
|
currency: string | null
|
||||||
|
financialStatus: string | null
|
||||||
|
fulfillmentStatus: string | null
|
||||||
|
createdAt: string
|
||||||
|
store: {
|
||||||
|
businessName: string
|
||||||
|
shopifyStoreName: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOrders(): Promise<AdminOrder[]> {
|
||||||
|
const res = await fetch(`${process.env.NEXTAUTH_URL}/api/admin/orders`, {
|
||||||
|
cache: "no-store",
|
||||||
|
})
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = (await res.json()) as { orders: AdminOrder[] }
|
||||||
|
return data.orders
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminOrdersPage() {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session || session.user.role !== "ADMIN") {
|
||||||
|
redirect("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
const orders = await getOrders()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 h-full">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-white tracking-tight">
|
||||||
|
Orders with Synced Products
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-400 mt-1 max-w-2xl">
|
||||||
|
Orders from vendor stores that contain products synced with suppliers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-5 overflow-auto">
|
||||||
|
<table className="min-w-full text-sm text-left">
|
||||||
|
<thead className="border-b border-white/10 text-xs uppercase tracking-wide text-slate-400">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Order</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Vendor Store</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Customer</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Total</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Financial</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Fulfillment</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">
|
||||||
|
{orders.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={7}
|
||||||
|
className="py-6 text-center text-sm text-slate-500"
|
||||||
|
>
|
||||||
|
No orders with synced products found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{orders.map((order) => (
|
||||||
|
<tr key={order.id} className="hover:bg-white/5">
|
||||||
|
<td className="py-3 pr-4 align-top">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blue-500/20 border border-blue-400/30 flex items-center justify-center">
|
||||||
|
<ShoppingCart className="w-4 h-4 text-blue-200" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-white">
|
||||||
|
#{order.orderNumber}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400">
|
||||||
|
Order ID: {order.id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 align-top">
|
||||||
|
<div className="text-xs font-semibold text-white">
|
||||||
|
{order.store.businessName}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400">
|
||||||
|
{order.store.shopifyStoreName}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 align-top text-xs text-slate-300">
|
||||||
|
{order.email || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 align-top text-xs text-slate-200">
|
||||||
|
{order.totalPrice} {order.currency || ""}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 align-top text-[11px] text-slate-300">
|
||||||
|
{order.financialStatus || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 align-top text-[11px] text-slate-300">
|
||||||
|
{order.fulfillmentStatus || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 align-top text-xs text-slate-400">
|
||||||
|
{new Date(order.createdAt).toLocaleString("en-US", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
386
src/app/admin/page.tsx
Normal file
386
src/app/admin/page.tsx
Normal file
@ -0,0 +1,386 @@
|
|||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
Link2,
|
||||||
|
Shuffle,
|
||||||
|
ShoppingCart,
|
||||||
|
Store,
|
||||||
|
Users
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
type ConnectionGroup = {
|
||||||
|
status: string
|
||||||
|
_count: { _all: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductSyncGroup = {
|
||||||
|
status: string
|
||||||
|
_count: { _all: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecentSyncError = {
|
||||||
|
id: string
|
||||||
|
resourceType: string
|
||||||
|
lastSyncAt: Date
|
||||||
|
errorMessage: string | null
|
||||||
|
store: {
|
||||||
|
shopifyStoreName: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecentEvent = {
|
||||||
|
id: string
|
||||||
|
shopifyProductTitle: string
|
||||||
|
status: string
|
||||||
|
createdAt: Date
|
||||||
|
store: {
|
||||||
|
shopifyStoreName: string
|
||||||
|
}
|
||||||
|
supplier: {
|
||||||
|
companyName: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminOverviewResult {
|
||||||
|
storeCount: number
|
||||||
|
supplierCount: number
|
||||||
|
connectionStats: ConnectionGroup[]
|
||||||
|
productSyncStats: ProductSyncGroup[]
|
||||||
|
orderCount: number
|
||||||
|
recentSyncErrors: RecentSyncError[]
|
||||||
|
recentEvents: RecentEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAdminOverview(): Promise<AdminOverviewResult> {
|
||||||
|
const [
|
||||||
|
storeCount,
|
||||||
|
supplierCount,
|
||||||
|
connectionStats,
|
||||||
|
productSyncStats,
|
||||||
|
orderCount,
|
||||||
|
recentSyncErrors,
|
||||||
|
recentEvents,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.store.count(),
|
||||||
|
prisma.supplierProfile.count(),
|
||||||
|
prisma.connection.groupBy({
|
||||||
|
by: ["status"],
|
||||||
|
_count: { _all: true },
|
||||||
|
}) as unknown as Promise<ConnectionGroup[]>,
|
||||||
|
// Use `as any` to be compatible with the current Prisma client setup
|
||||||
|
(prisma as any).productSync.groupBy({
|
||||||
|
by: ["status"],
|
||||||
|
_count: { _all: true },
|
||||||
|
}) as Promise<ProductSyncGroup[]>,
|
||||||
|
prisma.cachedOrder.count(),
|
||||||
|
prisma.syncMetadata.findMany({
|
||||||
|
where: { syncStatus: { in: ["failed"] } },
|
||||||
|
orderBy: { lastSyncAt: "desc" },
|
||||||
|
take: 5,
|
||||||
|
include: { store: true },
|
||||||
|
}) as Promise<RecentSyncError[]>,
|
||||||
|
(prisma as any).productSync.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 8,
|
||||||
|
include: {
|
||||||
|
store: true,
|
||||||
|
supplier: true,
|
||||||
|
},
|
||||||
|
}) as Promise<RecentEvent[]>,
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
storeCount,
|
||||||
|
supplierCount,
|
||||||
|
connectionStats,
|
||||||
|
productSyncStats,
|
||||||
|
orderCount,
|
||||||
|
recentSyncErrors,
|
||||||
|
recentEvents,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminOverviewPage() {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session || session.user.role !== "ADMIN") {
|
||||||
|
redirect("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
storeCount,
|
||||||
|
supplierCount,
|
||||||
|
connectionStats,
|
||||||
|
productSyncStats,
|
||||||
|
orderCount,
|
||||||
|
recentSyncErrors,
|
||||||
|
recentEvents,
|
||||||
|
} = await getAdminOverview()
|
||||||
|
|
||||||
|
const connectedCount =
|
||||||
|
connectionStats.find((c: ConnectionGroup) => c.status === "CONNECTED")?._count._all ?? 0
|
||||||
|
const pendingConnections =
|
||||||
|
connectionStats.find((c: ConnectionGroup) => c.status === "PENDING")?._count._all ?? 0
|
||||||
|
const rejectedConnections =
|
||||||
|
connectionStats.find((c: ConnectionGroup) => c.status === "REJECTED")?._count._all ?? 0
|
||||||
|
|
||||||
|
const pendingSyncs =
|
||||||
|
productSyncStats.find((c: ProductSyncGroup) => c.status === "PENDING")?._count._all ?? 0
|
||||||
|
const acceptedSyncs =
|
||||||
|
productSyncStats.find((c: ProductSyncGroup) => c.status === "ACCEPTED")?._count._all ?? 0
|
||||||
|
const rejectedSyncs =
|
||||||
|
productSyncStats.find((c: ProductSyncGroup) => c.status === "REJECTED")?._count._all ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8 h-full">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-white tracking-tight">
|
||||||
|
Platform Overview
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-400 mt-1 max-w-xl">
|
||||||
|
High-level, read-only visibility into how data flows between Shopify
|
||||||
|
stores, vendors and suppliers across JUNOHUB.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
|
Vendors & Stores
|
||||||
|
</p>
|
||||||
|
<p className="text-3xl font-bold text-white mt-2">{storeCount}</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
Total Shopify stores connected
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-11 h-11 rounded-2xl bg-indigo-500/30 border border-indigo-400/40 flex items-center justify-center">
|
||||||
|
<Store className="w-6 h-6 text-indigo-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
|
Suppliers
|
||||||
|
</p>
|
||||||
|
<p className="text-3xl font-bold text-white mt-2">{supplierCount}</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
Active supplier profiles
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-11 h-11 rounded-2xl bg-emerald-500/30 border border-emerald-400/40 flex items-center justify-center">
|
||||||
|
<Users className="w-6 h-6 text-emerald-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
|
Orders Indexed
|
||||||
|
</p>
|
||||||
|
<p className="text-3xl font-bold text-white mt-2">{orderCount}</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
Cached Shopify orders across all stores
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-11 h-11 rounded-2xl bg-blue-500/30 border border-blue-400/40 flex items-center justify-center">
|
||||||
|
<ShoppingCart className="w-6 h-6 text-blue-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 flex-1 min-h-0">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-5 flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
|
Connections
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-400 mt-1">
|
||||||
|
Vendor ↔ Supplier relationships
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 rounded-2xl bg-blue-500/20 border border-blue-400/30 flex items-center justify-center">
|
||||||
|
<Link2 className="w-5 h-5 text-blue-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3 mt-2">
|
||||||
|
<div className="rounded-xl bg-slate-900/60 border border-white/10 p-3">
|
||||||
|
<p className="text-[11px] text-slate-400 uppercase tracking-wide">
|
||||||
|
Connected
|
||||||
|
</p>
|
||||||
|
<p className="text-xl font-semibold text-emerald-300 mt-1">
|
||||||
|
{connectedCount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-slate-900/60 border border-white/10 p-3">
|
||||||
|
<p className="text-[11px] text-slate-400 uppercase tracking-wide">
|
||||||
|
Pending
|
||||||
|
</p>
|
||||||
|
<p className="text-xl font-semibold text-amber-300 mt-1">
|
||||||
|
{pendingConnections}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-slate-900/60 border border-white/10 p-3">
|
||||||
|
<p className="text-[11px] text-slate-400 uppercase tracking-wide">
|
||||||
|
Rejected
|
||||||
|
</p>
|
||||||
|
<p className="text-xl font-semibold text-rose-300 mt-1">
|
||||||
|
{rejectedConnections}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-5 flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
|
Product Syncs
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-400 mt-1">
|
||||||
|
Vendor product sharing with suppliers
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 rounded-2xl bg-purple-500/20 border border-purple-400/30 flex items-center justify-center">
|
||||||
|
<Shuffle className="w-5 h-5 text-purple-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3 mt-2">
|
||||||
|
<div className="rounded-xl bg-slate-900/60 border border-white/10 p-3">
|
||||||
|
<p className="text-[11px] text-slate-400 uppercase tracking-wide">
|
||||||
|
Pending
|
||||||
|
</p>
|
||||||
|
<p className="text-xl font-semibold text-amber-300 mt-1">
|
||||||
|
{pendingSyncs}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-slate-900/60 border border-white/10 p-3">
|
||||||
|
<p className="text-[11px] text-slate-400 uppercase tracking-wide">
|
||||||
|
Accepted
|
||||||
|
</p>
|
||||||
|
<p className="text-xl font-semibold text-emerald-300 mt-1">
|
||||||
|
{acceptedSyncs}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-slate-900/60 border border-white/10 p-3">
|
||||||
|
<p className="text-[11px] text-slate-400 uppercase tracking-wide">
|
||||||
|
Rejected
|
||||||
|
</p>
|
||||||
|
<p className="text-xl font-semibold text-rose-300 mt-1">
|
||||||
|
{rejectedSyncs}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-5 flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
|
Sync Health
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-400 mt-1">
|
||||||
|
Recent sync failures across all stores
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 rounded-2xl bg-rose-500/20 border border-rose-400/30 flex items-center justify-center">
|
||||||
|
<Activity className="w-5 h-5 text-rose-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 mt-1 overflow-y-auto max-h-64">
|
||||||
|
{recentSyncErrors.length === 0 && (
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
No recent sync failures detected.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{recentSyncErrors.map((sync: RecentSyncError) => (
|
||||||
|
<div
|
||||||
|
key={sync.id}
|
||||||
|
className="rounded-xl bg-slate-900/60 border border-rose-500/40 p-3"
|
||||||
|
>
|
||||||
|
<p className="text-xs font-semibold text-rose-200">
|
||||||
|
{sync.resourceType.toUpperCase()} ·{" "}
|
||||||
|
{sync.store.shopifyStoreName}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-slate-300 mt-1 line-clamp-2">
|
||||||
|
{sync.errorMessage || "Unknown error"}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-slate-500 mt-1">
|
||||||
|
Last attempt:{" "}
|
||||||
|
{sync.lastSyncAt.toLocaleString("en-US", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-5 flex flex-col gap-3 min-h-[220px]">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
|
Recent Events
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-400 mt-1">
|
||||||
|
Latest product sync activity across the network
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 rounded-2xl bg-slate-900/70 border border-white/10 flex items-center justify-center">
|
||||||
|
<Activity className="w-5 h-5 text-slate-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 space-y-2 overflow-y-auto">
|
||||||
|
{recentEvents.length === 0 && (
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
No recent product sync events.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{recentEvents.map((event: RecentEvent) => (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
className="flex items-start gap-3 rounded-xl bg-slate-900/60 border border-white/10 px-3 py-2.5"
|
||||||
|
>
|
||||||
|
<div className="mt-1">
|
||||||
|
<Shuffle className="w-4 h-4 text-purple-300" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs text-slate-200">
|
||||||
|
<span className="font-semibold">
|
||||||
|
{event.store.shopifyStoreName}
|
||||||
|
</span>{" "}
|
||||||
|
↔{" "}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{event.supplier.companyName}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-0.5 line-clamp-1">
|
||||||
|
{event.shopifyProductTitle}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-slate-500 mt-0.5">
|
||||||
|
Status:{" "}
|
||||||
|
<span className="font-medium text-slate-300">
|
||||||
|
{event.status}
|
||||||
|
</span>{" "}
|
||||||
|
·{" "}
|
||||||
|
{event.createdAt.toLocaleString("en-US", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
154
src/app/admin/product-syncs/page.tsx
Normal file
154
src/app/admin/product-syncs/page.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import { auth } from "@/auth"
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { Shuffle } from "lucide-react"
|
||||||
|
|
||||||
|
interface AdminProductSync {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
createdAt: string
|
||||||
|
acceptedAt: string | null
|
||||||
|
rejectedAt: string | null
|
||||||
|
shopifyProductId: string
|
||||||
|
shopifyProductTitle: string
|
||||||
|
store: {
|
||||||
|
businessName: string
|
||||||
|
shopifyStoreName: string
|
||||||
|
}
|
||||||
|
supplier: {
|
||||||
|
companyName: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProductSyncs(): Promise<AdminProductSync[]> {
|
||||||
|
const res = await fetch(`${process.env.NEXTAUTH_URL}/api/admin/product-syncs`, {
|
||||||
|
cache: "no-store",
|
||||||
|
})
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = (await res.json()) as { syncs: AdminProductSync[] }
|
||||||
|
return data.syncs
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminProductSyncsPage() {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session || session.user.role !== "ADMIN") {
|
||||||
|
redirect("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncs = await getProductSyncs()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 h-full">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-white tracking-tight">
|
||||||
|
Product Syncs
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-400 mt-1 max-w-2xl">
|
||||||
|
All vendor → supplier product sync requests, fully read-only.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-5 overflow-auto">
|
||||||
|
<table className="min-w-full text-sm text-left">
|
||||||
|
<thead className="border-b border-white/10 text-xs uppercase tracking-wide text-slate-400">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Product</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Vendor Store</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Supplier</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Status</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Requested</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Resolved At</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">
|
||||||
|
{syncs.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={6}
|
||||||
|
className="py-6 text-center text-sm text-slate-500"
|
||||||
|
>
|
||||||
|
No product syncs found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{syncs.map((sync) => (
|
||||||
|
<tr key={sync.id} className="hover:bg-white/5">
|
||||||
|
<td className="py-3 pr-4 align-top">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-purple-500/20 border border-purple-400/30 flex items-center justify-center mt-0.5">
|
||||||
|
<Shuffle className="w-4 h-4 text-purple-200" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-white line-clamp-1">
|
||||||
|
{sync.shopifyProductTitle}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400">
|
||||||
|
#{sync.shopifyProductId}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 align-top">
|
||||||
|
<div className="text-xs font-semibold text-white">
|
||||||
|
{sync.store.businessName}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400">
|
||||||
|
{sync.store.shopifyStoreName}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 align-top">
|
||||||
|
<div className="text-xs font-semibold text-white">
|
||||||
|
{sync.supplier.companyName}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 align-top">
|
||||||
|
<StatusPill status={sync.status} />
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 align-top text-xs text-slate-400">
|
||||||
|
{new Date(sync.createdAt).toLocaleString("en-US", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 align-top text-xs text-slate-400">
|
||||||
|
{sync.acceptedAt || sync.rejectedAt
|
||||||
|
? new Date(
|
||||||
|
(sync.acceptedAt || sync.rejectedAt) as string,
|
||||||
|
).toLocaleString("en-US", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
})
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: string }) {
|
||||||
|
const normalized = status.toUpperCase()
|
||||||
|
let color =
|
||||||
|
"bg-slate-800/60 text-slate-200 border-slate-500/40"
|
||||||
|
|
||||||
|
if (normalized === "ACCEPTED") {
|
||||||
|
color = "bg-emerald-500/15 text-emerald-200 border-emerald-500/40"
|
||||||
|
} else if (normalized === "PENDING") {
|
||||||
|
color = "bg-amber-500/15 text-amber-200 border-amber-500/40"
|
||||||
|
} else if (normalized === "REJECTED") {
|
||||||
|
color = "bg-rose-500/15 text-rose-200 border-rose-500/40"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide border ${color}`}
|
||||||
|
>
|
||||||
|
{normalized}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
145
src/app/admin/sync-health/page.tsx
Normal file
145
src/app/admin/sync-health/page.tsx
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { auth } from "@/auth"
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { Activity, Radar } from "lucide-react"
|
||||||
|
|
||||||
|
interface AdminSyncMetadata {
|
||||||
|
id: string
|
||||||
|
resourceType: string
|
||||||
|
lastSyncAt: string
|
||||||
|
syncStatus: string
|
||||||
|
recordCount: number
|
||||||
|
errorMessage: string | null
|
||||||
|
store: {
|
||||||
|
businessName: string
|
||||||
|
shopifyStoreName: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSyncHealth(): Promise<AdminSyncMetadata[]> {
|
||||||
|
const res = await fetch(`${process.env.NEXTAUTH_URL}/api/admin/sync-health`, {
|
||||||
|
cache: "no-store",
|
||||||
|
})
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = (await res.json()) as { syncs: AdminSyncMetadata[] }
|
||||||
|
return data.syncs
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminSyncHealthPage() {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session || session.user.role !== "ADMIN") {
|
||||||
|
redirect("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncs = await getSyncHealth()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 h-full">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-white tracking-tight">
|
||||||
|
Sync Health
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-400 mt-1 max-w-2xl">
|
||||||
|
Status of Shopify syncs (products, orders, customers) across all stores.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-5 overflow-auto">
|
||||||
|
<table className="min-w-full text-sm text-left">
|
||||||
|
<thead className="border-b border-white/10 text-xs uppercase tracking-wide text-slate-400">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Store</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Resource</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Status</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Records</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Last Sync</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Error</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">
|
||||||
|
{syncs.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={6}
|
||||||
|
className="py-6 text-center text-sm text-slate-500"
|
||||||
|
>
|
||||||
|
No sync metadata found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{syncs.map((sync) => (
|
||||||
|
<tr key={sync.id} className="hover:bg-white/5">
|
||||||
|
<td className="py-3 pr-4 align-top">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-cyan-500/20 border border-cyan-400/30 flex items-center justify-center">
|
||||||
|
<Radar className="w-4 h-4 text-cyan-200" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-white">
|
||||||
|
{sync.store.businessName}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400">
|
||||||
|
{sync.store.shopifyStoreName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 align-top text-xs text-slate-200">
|
||||||
|
{sync.resourceType.toUpperCase()}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 align-top">
|
||||||
|
<StatusPill status={sync.syncStatus} />
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 align-top text-xs text-slate-200">
|
||||||
|
{sync.recordCount}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 align-top text-xs text-slate-400">
|
||||||
|
{new Date(sync.lastSyncAt).toLocaleString("en-US", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 align-top text-[11px] text-rose-300">
|
||||||
|
{sync.errorMessage ? (
|
||||||
|
<div className="flex items-start gap-1">
|
||||||
|
<Activity className="w-3 h-3 mt-0.5" />
|
||||||
|
<span className="line-clamp-2">
|
||||||
|
{sync.errorMessage}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-500">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: string }) {
|
||||||
|
const normalized = status.toUpperCase()
|
||||||
|
let color =
|
||||||
|
"bg-slate-800/60 text-slate-200 border-slate-500/40"
|
||||||
|
|
||||||
|
if (normalized === "SUCCESS") {
|
||||||
|
color = "bg-emerald-500/15 text-emerald-200 border-emerald-500/40"
|
||||||
|
} else if (normalized === "IN_PROGRESS") {
|
||||||
|
color = "bg-amber-500/15 text-amber-200 border-amber-500/40"
|
||||||
|
} else if (normalized === "FAILED") {
|
||||||
|
color = "bg-rose-500/15 text-rose-200 border-rose-500/40"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide border ${color}`}
|
||||||
|
>
|
||||||
|
{normalized}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
38
src/app/api/admin/connections/route.ts
Normal file
38
src/app/api/admin/connections/route.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "ADMIN") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const status = searchParams.get("status")
|
||||||
|
|
||||||
|
const connections = await prisma.connection.findMany({
|
||||||
|
where: {
|
||||||
|
...(status && { status: status as any }),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
store: true,
|
||||||
|
supplier: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ connections })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching admin connections:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to fetch connections" },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
47
src/app/api/admin/emails/route.ts
Normal file
47
src/app/api/admin/emails/route.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "ADMIN") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||||
|
const storeId = searchParams.get("storeId")
|
||||||
|
const status = searchParams.get("status")
|
||||||
|
const trigger = searchParams.get("trigger")
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const logs = await (prisma as any).emailLog.findMany({
|
||||||
|
where: {
|
||||||
|
...(storeId && { storeId }),
|
||||||
|
...(status && { status }),
|
||||||
|
...(trigger && { trigger }),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
store: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
sentAt: "desc",
|
||||||
|
},
|
||||||
|
take: limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const total = await (prisma as any).emailLog.count()
|
||||||
|
|
||||||
|
return NextResponse.json({ emails: logs, total })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching admin email logs:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to fetch email logs" },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
93
src/app/api/admin/events/route.ts
Normal file
93
src/app/api/admin/events/route.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
type AdminEventType = "CONNECTION" | "PRODUCT_SYNC" | "SYNC_HEALTH"
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "ADMIN") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||||
|
const typeFilter = searchParams.get("type") as AdminEventType | null
|
||||||
|
|
||||||
|
const [connectionEvents, productSyncEvents, syncHealthEvents] = await Promise.all([
|
||||||
|
prisma.connection.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: limit,
|
||||||
|
include: {
|
||||||
|
store: true,
|
||||||
|
supplier: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
(prisma as any).productSync.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: limit,
|
||||||
|
include: {
|
||||||
|
store: true,
|
||||||
|
supplier: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.syncMetadata.findMany({
|
||||||
|
orderBy: { lastSyncAt: "desc" },
|
||||||
|
take: limit,
|
||||||
|
include: {
|
||||||
|
store: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
...connectionEvents.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
type: "CONNECTION" as AdminEventType,
|
||||||
|
status: c.status,
|
||||||
|
timestamp: c.createdAt,
|
||||||
|
storeName: c.store.shopifyStoreName,
|
||||||
|
supplierName: c.supplier.companyName,
|
||||||
|
metadata: {},
|
||||||
|
})),
|
||||||
|
...productSyncEvents.map((p: any) => ({
|
||||||
|
id: p.id,
|
||||||
|
type: "PRODUCT_SYNC" as AdminEventType,
|
||||||
|
status: p.status,
|
||||||
|
timestamp: p.createdAt,
|
||||||
|
storeName: p.store.shopifyStoreName,
|
||||||
|
supplierName: p.supplier.companyName,
|
||||||
|
metadata: {
|
||||||
|
productTitle: p.shopifyProductTitle,
|
||||||
|
shopifyProductId: p.shopifyProductId,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
...syncHealthEvents.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
type: "SYNC_HEALTH" as AdminEventType,
|
||||||
|
status: s.syncStatus,
|
||||||
|
timestamp: s.lastSyncAt,
|
||||||
|
storeName: s.store.shopifyStoreName,
|
||||||
|
supplierName: null,
|
||||||
|
metadata: {
|
||||||
|
resourceType: s.resourceType,
|
||||||
|
recordCount: s.recordCount,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
.filter((event) => !typeFilter || event.type === typeFilter)
|
||||||
|
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
|
||||||
|
.slice(0, limit)
|
||||||
|
|
||||||
|
return NextResponse.json({ events })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching admin events:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to fetch events" },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
126
src/app/api/admin/orders/route.ts
Normal file
126
src/app/api/admin/orders/route.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "ADMIN") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all accepted product syncs
|
||||||
|
const acceptedSyncs = await (prisma as any).productSync.findMany({
|
||||||
|
where: {
|
||||||
|
status: "ACCEPTED",
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
shopifyProductId: true,
|
||||||
|
storeId: true,
|
||||||
|
shopifyProductTitle: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (acceptedSyncs.length === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
orders: [],
|
||||||
|
syncedProducts: 0,
|
||||||
|
totalOrders: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncsByStore = new Map<string, Set<string>>()
|
||||||
|
acceptedSyncs.forEach((sync: any) => {
|
||||||
|
if (!syncsByStore.has(sync.storeId)) {
|
||||||
|
syncsByStore.set(sync.storeId, new Set())
|
||||||
|
}
|
||||||
|
syncsByStore.get(sync.storeId)!.add(sync.shopifyProductId)
|
||||||
|
})
|
||||||
|
|
||||||
|
const storeIds = Array.from(syncsByStore.keys())
|
||||||
|
const cachedOrders = await prisma.cachedOrder.findMany({
|
||||||
|
where: {
|
||||||
|
storeId: { in: storeIds },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
store: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
businessName: true,
|
||||||
|
shopifyStoreName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
shopifyCreatedAt: "desc",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const relevantOrders = cachedOrders.filter((order) => {
|
||||||
|
const syncedProductIds = syncsByStore.get(order.storeId)
|
||||||
|
if (!syncedProductIds || syncedProductIds.size === 0) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lineItems =
|
||||||
|
typeof order.lineItems === "string"
|
||||||
|
? JSON.parse(order.lineItems)
|
||||||
|
: order.lineItems
|
||||||
|
|
||||||
|
if (!Array.isArray(lineItems)) return false
|
||||||
|
|
||||||
|
return lineItems.some((item: any) => {
|
||||||
|
const productId = item.product_id?.toString()
|
||||||
|
return productId && syncedProductIds.has(productId)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const transformedOrders = relevantOrders.map((order) => {
|
||||||
|
const syncedProductIds = syncsByStore.get(order.storeId) || new Set()
|
||||||
|
let lineItems: any[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
lineItems =
|
||||||
|
typeof order.lineItems === "string"
|
||||||
|
? JSON.parse(order.lineItems)
|
||||||
|
: order.lineItems
|
||||||
|
} catch {
|
||||||
|
lineItems = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncedLineItems = lineItems.filter((item: any) => {
|
||||||
|
const productId = item.product_id?.toString()
|
||||||
|
return productId && syncedProductIds.has(productId)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: order.shopifyOrderId,
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
email: order.email,
|
||||||
|
totalPrice: order.totalPrice,
|
||||||
|
currency: order.currency,
|
||||||
|
financialStatus: order.financialStatus,
|
||||||
|
fulfillmentStatus: order.fulfillmentStatus,
|
||||||
|
createdAt: order.shopifyCreatedAt || order.createdAt,
|
||||||
|
store: order.store,
|
||||||
|
lineItems: syncedLineItems,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
orders: transformedOrders,
|
||||||
|
syncedProducts: acceptedSyncs.length,
|
||||||
|
totalOrders: transformedOrders.length,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching admin orders:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to fetch orders" },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
38
src/app/api/admin/product-syncs/route.ts
Normal file
38
src/app/api/admin/product-syncs/route.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "ADMIN") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const status = searchParams.get("status")
|
||||||
|
|
||||||
|
const syncs = await (prisma as any).productSync.findMany({
|
||||||
|
where: {
|
||||||
|
...(status && { status }),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
store: true,
|
||||||
|
supplier: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ syncs })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching admin product syncs:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to fetch product syncs" },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
31
src/app/api/admin/sync-health/route.ts
Normal file
31
src/app/api/admin/sync-health/route.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "ADMIN") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncs = await prisma.syncMetadata.findMany({
|
||||||
|
include: {
|
||||||
|
store: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
lastSyncAt: "desc",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ syncs })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching admin sync health:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to fetch sync health" },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
2
src/app/api/auth/[...nextauth]/route.ts
Normal file
2
src/app/api/auth/[...nextauth]/route.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import { handlers } from "@/auth"
|
||||||
|
export const { GET, POST } = handlers
|
||||||
162
src/app/api/auth/login/route.ts
Normal file
162
src/app/api/auth/login/route.ts
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import bcrypt from "bcryptjs"
|
||||||
|
import { decryptString } from "@/lib/encryption"
|
||||||
|
import { applyRateLimit } from "@/lib/rate-limit"
|
||||||
|
import { authLoginSchema } from "@/lib/validation"
|
||||||
|
import { logger } from "@/lib/logger"
|
||||||
|
|
||||||
|
const ADMIN_EMAIL = process.env.ADMIN_EMAIL
|
||||||
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
// Basic rate limiting per IP for login attempts
|
||||||
|
const rate = await applyRateLimit(req, "auth:login")
|
||||||
|
if (!rate.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Too many login attempts. Please try again later." },
|
||||||
|
{ status: 429 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const parseResult = authLoginSchema.safeParse(body)
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
const message = parseResult.error.issues[0]?.message || "Invalid login data."
|
||||||
|
return NextResponse.json({ message }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email, password, mfaCode } = parseResult.data
|
||||||
|
|
||||||
|
if (!email || !password || email.trim() === "" || password.trim() === "") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Email and password are required" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special-case env-configured admin user so it can log in even if not pre-seeded
|
||||||
|
if (ADMIN_EMAIL && ADMIN_PASSWORD && email === ADMIN_EMAIL) {
|
||||||
|
const hashedPassword = await bcrypt.hash(ADMIN_PASSWORD, 10)
|
||||||
|
|
||||||
|
const adminUser = await prisma.user.upsert({
|
||||||
|
where: { email: ADMIN_EMAIL },
|
||||||
|
update: {
|
||||||
|
password: hashedPassword,
|
||||||
|
role: "ADMIN",
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
email: ADMIN_EMAIL,
|
||||||
|
password: hashedPassword,
|
||||||
|
role: "ADMIN",
|
||||||
|
name: "Admin",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const isValidAdminPassword = await bcrypt.compare(password, adminUser.password!)
|
||||||
|
if (!isValidAdminPassword) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Invalid email or password" },
|
||||||
|
{ status: 401 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin currently does not go through MFA in this flow
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Authentication successful",
|
||||||
|
requiresMFA: false,
|
||||||
|
verified: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular user login flow
|
||||||
|
const userResult = await prisma.$queryRawUnsafe<
|
||||||
|
Array<{
|
||||||
|
id: string
|
||||||
|
password: string | null
|
||||||
|
mfaEnabled: boolean | null
|
||||||
|
mfaSecret: string | null
|
||||||
|
}>
|
||||||
|
>(
|
||||||
|
`SELECT id, password, "mfaEnabled", "mfaSecret" FROM "User" WHERE email = $1`,
|
||||||
|
email,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!userResult || userResult.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Invalid email or password" },
|
||||||
|
{ status: 401 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userResult[0]
|
||||||
|
|
||||||
|
if (!user.password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Invalid email or password" },
|
||||||
|
{ status: 401 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const isValidPassword = await bcrypt.compare(password, user.password)
|
||||||
|
if (!isValidPassword) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Invalid email or password" },
|
||||||
|
{ status: 401 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if MFA is enabled
|
||||||
|
const mfaEnabled = user.mfaEnabled ?? false
|
||||||
|
|
||||||
|
if (mfaEnabled) {
|
||||||
|
// If MFA code is not provided, return that MFA is required
|
||||||
|
if (!mfaCode) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
message: "MFA verification required",
|
||||||
|
requiresMFA: true
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify MFA code
|
||||||
|
const mfaSecret = decryptString(user.mfaSecret)
|
||||||
|
if (!mfaSecret) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "MFA is enabled but no secret found. Please contact support." },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { verify } = await import("otplib")
|
||||||
|
const verifyResult = await verify({ token: mfaCode, secret: mfaSecret })
|
||||||
|
const isValidMFA = verifyResult.valid ?? false
|
||||||
|
|
||||||
|
if (!isValidMFA) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Invalid verification code" },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get here, credentials and MFA (if enabled) are valid
|
||||||
|
// Return success - the client will call NextAuth signIn
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Authentication successful",
|
||||||
|
requiresMFA: false,
|
||||||
|
verified: true
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Login error", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
116
src/app/api/auth/password-reset/request/route.ts
Normal file
116
src/app/api/auth/password-reset/request/route.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { sendEmail } from "@/lib/email"
|
||||||
|
import { emailSchema } from "@/lib/validation"
|
||||||
|
import { applyRateLimit } from "@/lib/rate-limit"
|
||||||
|
import { handleApiError } from "@/lib/errors"
|
||||||
|
import crypto from "crypto"
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const rate = await applyRateLimit(req, "auth:password-reset-request")
|
||||||
|
if (!rate.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Too many requests. Please try again later." },
|
||||||
|
{ status: 429 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const emailResult = emailSchema.safeParse(body.email)
|
||||||
|
|
||||||
|
if (!emailResult.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: emailResult.error.errors[0].message },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = emailResult.data
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Don't reveal if user exists or not (security best practice)
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "If an account with that email exists, a password reset link has been sent."
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate reset token
|
||||||
|
const resetToken = crypto.randomBytes(32).toString("hex")
|
||||||
|
const expiresAt = new Date()
|
||||||
|
expiresAt.setHours(expiresAt.getHours() + 1) // Token expires in 1 hour
|
||||||
|
|
||||||
|
// Store token in VerificationToken table
|
||||||
|
await prisma.verificationToken.upsert({
|
||||||
|
where: {
|
||||||
|
identifier_token: {
|
||||||
|
identifier: email,
|
||||||
|
token: resetToken
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
token: resetToken,
|
||||||
|
expires: expiresAt
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
identifier: email,
|
||||||
|
token: resetToken,
|
||||||
|
expires: expiresAt
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Send reset email
|
||||||
|
const resetUrl = `${process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/reset-password?token=${resetToken}&email=${encodeURIComponent(email)}`
|
||||||
|
|
||||||
|
const emailHtml = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Password Reset - JUNOHUB</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #0a0e1a;">
|
||||||
|
<table role="presentation" style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px 20px; text-align: center;">
|
||||||
|
<table role="presentation" style="max-width: 600px; margin: 0 auto; background-color: #1e293b; border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.1); overflow: hidden;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px;">
|
||||||
|
<h2 style="margin: 0 0 16px; font-size: 24px; font-weight: 700; color: #ffffff;">Reset Your Password</h2>
|
||||||
|
<p style="margin: 0 0 24px; font-size: 16px; line-height: 1.6; color: #cbd5e1;">
|
||||||
|
Click the button below to reset your password. This link will expire in 1 hour.
|
||||||
|
</p>
|
||||||
|
<a href="${resetUrl}" style="display: inline-block; padding: 14px 32px; background-color: #4f46e5; color: #ffffff; text-decoration: none; border-radius: 10px; font-weight: 700; font-size: 16px;">
|
||||||
|
Reset Password
|
||||||
|
</a>
|
||||||
|
<p style="margin: 24px 0 0; font-size: 14px; color: #94a3b8;">
|
||||||
|
If you didn't request this, you can safely ignore this email.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: "Reset Your JUNOHUB Password",
|
||||||
|
html: emailHtml
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "If an account with that email exists, a password reset link has been sent."
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error, "password-reset-request")
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/app/api/auth/password-reset/reset/route.ts
Normal file
86
src/app/api/auth/password-reset/reset/route.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { passwordSchema, emailSchema } from "@/lib/validation"
|
||||||
|
import { applyRateLimit } from "@/lib/rate-limit"
|
||||||
|
import { handleApiError } from "@/lib/errors"
|
||||||
|
import bcrypt from "bcryptjs"
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const rate = await applyRateLimit(req, "auth:password-reset")
|
||||||
|
if (!rate.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Too many requests. Please try again later." },
|
||||||
|
{ status: 429 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const { token, email, password } = body
|
||||||
|
|
||||||
|
if (!token || !email || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Token, email, and password are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email and password
|
||||||
|
const emailResult = emailSchema.safeParse(email)
|
||||||
|
const passwordResult = passwordSchema.safeParse(password)
|
||||||
|
|
||||||
|
if (!emailResult.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: emailResult.error.errors[0].message },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passwordResult.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: passwordResult.error.errors[0].message },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify token
|
||||||
|
const verificationToken = await prisma.verificationToken.findUnique({
|
||||||
|
where: {
|
||||||
|
identifier_token: {
|
||||||
|
identifier: email,
|
||||||
|
token: token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!verificationToken || verificationToken.expires < new Date()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Invalid or expired reset token" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update password
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10)
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { email },
|
||||||
|
data: { password: hashedPassword }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete used token
|
||||||
|
await prisma.verificationToken.delete({
|
||||||
|
where: {
|
||||||
|
identifier_token: {
|
||||||
|
identifier: email,
|
||||||
|
token: token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Password has been reset successfully"
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error, "password-reset")
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/app/api/auth/register/route.ts
Normal file
60
src/app/api/auth/register/route.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import bcrypt from "bcryptjs"
|
||||||
|
import { applyRateLimit } from "@/lib/rate-limit"
|
||||||
|
import { authRegisterSchema } from "@/lib/validation"
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
|
||||||
|
// Basic rate limiting per IP for registrations
|
||||||
|
const rate = await applyRateLimit(req, "auth:register")
|
||||||
|
if (!rate.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Too many signup attempts. Please try again later." },
|
||||||
|
{ status: 429 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseResult = authRegisterSchema.safeParse(body)
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
const message = parseResult.error.issues[0]?.message || "Invalid registration data."
|
||||||
|
return NextResponse.json({ message }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, email, password, role } = parseResult.data
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { email }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return NextResponse.json({ message: "User already exists with this email." }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10)
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
password: hashedPassword,
|
||||||
|
role: role || "VENDOR"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "User registered successfully!",
|
||||||
|
user: { id: user.id, name: user.name, email: user.email }
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Registration error:", error)
|
||||||
|
return NextResponse.json({ message: "Internal server error during registration." }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/app/api/auth/verify-email/send/route.ts
Normal file
115
src/app/api/auth/verify-email/send/route.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { sendEmail } from "@/lib/email"
|
||||||
|
import { applyRateLimit } from "@/lib/rate-limit"
|
||||||
|
import { handleApiError } from "@/lib/errors"
|
||||||
|
import crypto from "crypto"
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const rate = await applyRateLimit(req, "auth:verify-email-send")
|
||||||
|
if (!rate.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Too many requests. Please try again later." },
|
||||||
|
{ status: 429 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user || !user.email) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "User not found or email not set" },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.emailVerified) {
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Email is already verified"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate verification token
|
||||||
|
const verificationToken = crypto.randomBytes(32).toString("hex")
|
||||||
|
const expiresAt = new Date()
|
||||||
|
expiresAt.setHours(expiresAt.getHours() + 24) // Token expires in 24 hours
|
||||||
|
|
||||||
|
// Store token
|
||||||
|
await prisma.verificationToken.upsert({
|
||||||
|
where: {
|
||||||
|
identifier_token: {
|
||||||
|
identifier: user.email,
|
||||||
|
token: verificationToken
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
token: verificationToken,
|
||||||
|
expires: expiresAt
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
identifier: user.email,
|
||||||
|
token: verificationToken,
|
||||||
|
expires: expiresAt
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Send verification email
|
||||||
|
const verifyUrl = `${process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/verify-email?token=${verificationToken}&email=${encodeURIComponent(user.email)}`
|
||||||
|
|
||||||
|
const emailHtml = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Verify Your Email - JUNOHUB</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #0a0e1a;">
|
||||||
|
<table role="presentation" style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px 20px; text-align: center;">
|
||||||
|
<table role="presentation" style="max-width: 600px; margin: 0 auto; background-color: #1e293b; border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.1); overflow: hidden;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px;">
|
||||||
|
<h2 style="margin: 0 0 16px; font-size: 24px; font-weight: 700; color: #ffffff;">Verify Your Email</h2>
|
||||||
|
<p style="margin: 0 0 24px; font-size: 16px; line-height: 1.6; color: #cbd5e1;">
|
||||||
|
Please verify your email address to complete your account setup.
|
||||||
|
</p>
|
||||||
|
<a href="${verifyUrl}" style="display: inline-block; padding: 14px 32px; background-color: #4f46e5; color: #ffffff; text-decoration: none; border-radius: 10px; font-weight: 700; font-size: 16px;">
|
||||||
|
Verify Email
|
||||||
|
</a>
|
||||||
|
<p style="margin: 24px 0 0; font-size: 14px; color: #94a3b8;">
|
||||||
|
This link will expire in 24 hours.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
to: user.email,
|
||||||
|
subject: "Verify Your JUNOHUB Email",
|
||||||
|
html: emailHtml
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Verification email sent successfully"
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error, "verify-email-send")
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/app/api/auth/verify-email/verify/route.ts
Normal file
65
src/app/api/auth/verify-email/verify/route.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { applyRateLimit } from "@/lib/rate-limit"
|
||||||
|
import { handleApiError } from "@/lib/errors"
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const rate = await applyRateLimit(req, "auth:verify-email")
|
||||||
|
if (!rate.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Too many requests. Please try again later." },
|
||||||
|
{ status: 429 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const { token, email } = body
|
||||||
|
|
||||||
|
if (!token || !email) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Token and email are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify token
|
||||||
|
const verificationToken = await prisma.verificationToken.findUnique({
|
||||||
|
where: {
|
||||||
|
identifier_token: {
|
||||||
|
identifier: email,
|
||||||
|
token: token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!verificationToken || verificationToken.expires < new Date()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Invalid or expired verification token" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user emailVerified
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { email },
|
||||||
|
data: { emailVerified: new Date() }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete used token
|
||||||
|
await prisma.verificationToken.delete({
|
||||||
|
where: {
|
||||||
|
identifier_token: {
|
||||||
|
identifier: email,
|
||||||
|
token: token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Email verified successfully"
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error, "verify-email")
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/app/api/auth/verify-mfa/route.ts
Normal file
73
src/app/api/auth/verify-mfa/route.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { verify } from "otplib"
|
||||||
|
import { decryptString } from "@/lib/encryption"
|
||||||
|
import { applyRateLimit } from "@/lib/rate-limit"
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const { email, code } = await req.json()
|
||||||
|
|
||||||
|
// Rate limit MFA verification attempts per IP
|
||||||
|
const rate = await applyRateLimit(req, "auth:mfa-verify")
|
||||||
|
if (!rate.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Too many verification attempts. Please try again later." },
|
||||||
|
{ status: 429 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email || !code) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Email and verification code are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use raw query to access MFA fields until Prisma client is fully regenerated
|
||||||
|
const userResult = await prisma.$queryRawUnsafe<Array<{ mfaEnabled: boolean | null; mfaSecret: string | null }>>(
|
||||||
|
`SELECT "mfaEnabled", "mfaSecret" FROM "User" WHERE email = $1`,
|
||||||
|
email
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!userResult || userResult.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "User not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userResult[0]
|
||||||
|
const mfaEnabled = user.mfaEnabled ?? false
|
||||||
|
const mfaSecret = decryptString(user.mfaSecret)
|
||||||
|
|
||||||
|
if (!mfaEnabled || !mfaSecret) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "MFA is not enabled for this account" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the TOTP code
|
||||||
|
const verifyResult = await verify({ token: code, secret: mfaSecret })
|
||||||
|
const isValid = verifyResult.valid ?? false
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Invalid verification code" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "MFA verification successful",
|
||||||
|
verified: true
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("MFA verification error:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
163
src/app/api/integrations/gmail/callback/route.ts
Normal file
163
src/app/api/integrations/gmail/callback/route.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/integrations/gmail/callback
|
||||||
|
* Handle Google OAuth callback and store tokens
|
||||||
|
*/
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/login?error=unauthorized", process.env.NEXTAUTH_URL || "http://localhost:3000")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const code = searchParams.get("code")
|
||||||
|
const error = searchParams.get("error")
|
||||||
|
const state = searchParams.get("state")
|
||||||
|
|
||||||
|
// Handle OAuth errors
|
||||||
|
if (error) {
|
||||||
|
console.error("Gmail OAuth error:", error)
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/dashboard/juno-engine/gmail-connect?error=oauth_failed", process.env.NEXTAUTH_URL || "http://localhost:3000")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/dashboard/juno-engine/gmail-connect?error=no_code", process.env.NEXTAUTH_URL || "http://localhost:3000")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange authorization code for tokens
|
||||||
|
// 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 redirectUri = `${baseUrl}/api/integrations/gmail/callback`
|
||||||
|
|
||||||
|
console.log("Gmail OAuth Callback - Redirect URI:", redirectUri) // Log for debugging
|
||||||
|
|
||||||
|
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: process.env.GOOGLE_CLIENT_ID!,
|
||||||
|
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||||
|
code,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!tokenResponse.ok) {
|
||||||
|
const errorData = await tokenResponse.json().catch(() => ({}))
|
||||||
|
console.error("Token exchange error:", errorData)
|
||||||
|
console.error("Redirect URI used:", redirectUri)
|
||||||
|
|
||||||
|
// Provide more specific error message if it's a redirect_uri_mismatch
|
||||||
|
if (errorData.error === "redirect_uri_mismatch") {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL(
|
||||||
|
`/dashboard/juno-engine/gmail-connect?error=redirect_uri_mismatch&redirect_uri=${encodeURIComponent(redirectUri)}`,
|
||||||
|
process.env.NEXTAUTH_URL || "http://localhost:3000"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/dashboard/juno-engine/gmail-connect?error=token_exchange_failed", process.env.NEXTAUTH_URL || "http://localhost:3000")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await tokenResponse.json()
|
||||||
|
|
||||||
|
// Get user's Gmail email address
|
||||||
|
// Try userinfo endpoint first (requires email/profile scopes)
|
||||||
|
let userInfo: { email: string } | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userInfoResponse = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokens.access_token}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (userInfoResponse.ok) {
|
||||||
|
userInfo = await userInfoResponse.json()
|
||||||
|
} else {
|
||||||
|
// Fallback: Try Gmail API profile endpoint
|
||||||
|
const gmailProfileResponse = await fetch("https://gmail.googleapis.com/gmail/v1/users/me/profile", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokens.access_token}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (gmailProfileResponse.ok) {
|
||||||
|
const profile = await gmailProfileResponse.json()
|
||||||
|
userInfo = { email: profile.emailAddress }
|
||||||
|
} else {
|
||||||
|
const errorData = await gmailProfileResponse.json().catch(() => ({}))
|
||||||
|
console.error("Failed to fetch user info from both endpoints:", {
|
||||||
|
userinfo: userInfoResponse.status,
|
||||||
|
gmail: gmailProfileResponse.status,
|
||||||
|
error: errorData
|
||||||
|
})
|
||||||
|
throw new Error("Failed to fetch user email")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching user info:", error)
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/dashboard/juno-engine/gmail-connect?error=user_info_failed", process.env.NEXTAUTH_URL || "http://localhost:3000")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userInfo?.email) {
|
||||||
|
console.error("No email found in user info")
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/dashboard/juno-engine/gmail-connect?error=user_info_failed", process.env.NEXTAUTH_URL || "http://localhost:3000")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate token expiration
|
||||||
|
const expiresAt = new Date(Date.now() + (tokens.expires_in * 1000))
|
||||||
|
|
||||||
|
// Store or update Gmail integration
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await (prisma as any).gmailIntegration.upsert({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: session.user.id,
|
||||||
|
accessToken: tokens.access_token,
|
||||||
|
refreshToken: tokens.refresh_token || null,
|
||||||
|
expiresAt,
|
||||||
|
email: userInfo.email,
|
||||||
|
scope: tokens.scope || ""
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
accessToken: tokens.access_token,
|
||||||
|
refreshToken: tokens.refresh_token || null,
|
||||||
|
expiresAt,
|
||||||
|
email: userInfo.email,
|
||||||
|
scope: tokens.scope || ""
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/dashboard/juno-engine/gmail-connect?connected=true", process.env.NEXTAUTH_URL || "http://localhost:3000")
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Gmail callback error:", error)
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/dashboard/juno-engine/gmail-connect?error=callback_failed", process.env.NEXTAUTH_URL || "http://localhost:3000")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/app/api/integrations/gmail/connect/route.ts
Normal file
68
src/app/api/integrations/gmail/connect/route.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import crypto from "crypto"
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if Google OAuth credentials are configured
|
||||||
|
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Google OAuth credentials not configured. Please set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET environment variables." },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate state token for CSRF protection
|
||||||
|
const state = crypto.randomBytes(32).toString("hex")
|
||||||
|
|
||||||
|
// Store state in a cookie or session (for simplicity, we'll include it in the callback URL)
|
||||||
|
// In production, use a proper session store or encrypted cookie
|
||||||
|
|
||||||
|
// Gmail API scopes
|
||||||
|
// Adding 'email' and 'profile' scopes to get user email address
|
||||||
|
const scopes = [
|
||||||
|
"https://www.googleapis.com/auth/gmail.readonly",
|
||||||
|
"https://www.googleapis.com/auth/gmail.send",
|
||||||
|
"https://www.googleapis.com/auth/gmail.modify",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.email",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.profile"
|
||||||
|
].join(" ")
|
||||||
|
|
||||||
|
// Build Google OAuth 2.0 authorization URL
|
||||||
|
// IMPORTANT: The redirect URI must exactly match what's configured in Google Cloud Console
|
||||||
|
// For production: https://yourdomain.com/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
|
||||||
|
const baseUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"
|
||||||
|
const redirectUri = `${baseUrl}/api/integrations/gmail/callback`
|
||||||
|
|
||||||
|
console.log("Gmail OAuth - Redirect URI:", redirectUri) // Log for debugging
|
||||||
|
|
||||||
|
const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth")
|
||||||
|
authUrl.searchParams.set("client_id", process.env.GOOGLE_CLIENT_ID)
|
||||||
|
authUrl.searchParams.set("redirect_uri", redirectUri)
|
||||||
|
authUrl.searchParams.set("response_type", "code")
|
||||||
|
authUrl.searchParams.set("scope", scopes)
|
||||||
|
authUrl.searchParams.set("access_type", "offline") // Required to get refresh token
|
||||||
|
authUrl.searchParams.set("prompt", "consent") // Force consent to get refresh token
|
||||||
|
authUrl.searchParams.set("state", state)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
authUrl: authUrl.toString(),
|
||||||
|
state, // Return state so frontend can verify it on callback
|
||||||
|
redirectUri, // Include redirect URI in response for debugging
|
||||||
|
message: "Redirecting to Google OAuth"
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error initiating Gmail OAuth:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to initiate Gmail connection" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/app/api/integrations/gmail/disconnect/route.ts
Normal file
26
src/app/api/integrations/gmail/disconnect/route.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { handleApiError } from "@/lib/errors"
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete Gmail integration
|
||||||
|
await prisma.gmailIntegration.deleteMany({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Gmail disconnected successfully"
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error, "gmail-disconnect")
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/app/api/integrations/gmail/poll/route.ts
Normal file
115
src/app/api/integrations/gmail/poll/route.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { logger } from "@/lib/logger"
|
||||||
|
import { getUnreadEmails, sendGmailReply, markEmailAsRead } from "@/lib/gmail/emailService"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/integrations/gmail/poll
|
||||||
|
* Manually trigger email polling and automated replies
|
||||||
|
* This can be called periodically via cron job or manually
|
||||||
|
*/
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's active store
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
include: {
|
||||||
|
stores: {
|
||||||
|
where: { isActive: true },
|
||||||
|
take: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const storeId = user?.stores[0]?.id
|
||||||
|
|
||||||
|
// Process incoming emails
|
||||||
|
const unreadEmails = await getUnreadEmails(session.user.id, 10)
|
||||||
|
const processed: string[] = []
|
||||||
|
const failed: string[] = []
|
||||||
|
|
||||||
|
for (const email of unreadEmails) {
|
||||||
|
try {
|
||||||
|
// Extract email domain from sender
|
||||||
|
const emailMatch = email.from.match(/<(.+)>/)
|
||||||
|
const senderEmail = emailMatch ? emailMatch[1] : email.from.split(" ").pop() || email.from
|
||||||
|
const senderDomain = senderEmail.split("@")[1]?.toLowerCase()
|
||||||
|
|
||||||
|
// Only reply to gmail.com addresses
|
||||||
|
if (senderDomain !== "gmail.com") {
|
||||||
|
logger.info("Skipping email - not from gmail.com", {
|
||||||
|
userId: session.user.id,
|
||||||
|
from: email.from,
|
||||||
|
domain: senderDomain
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send automated reply (dummy response for testing)
|
||||||
|
const replySubject = email.subject.startsWith("Re:") ? email.subject : `Re: ${email.subject}`
|
||||||
|
const replyBody = `
|
||||||
|
<div style="font-family: Arial, sans-serif; padding: 20px;">
|
||||||
|
<p>Thank you for your email. This is an automated reply.</p>
|
||||||
|
<p>We have received your message and will get back to you soon.</p>
|
||||||
|
<br/>
|
||||||
|
<p>Best regards,<br/>JUNOHUB Support Team</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await sendGmailReply(
|
||||||
|
session.user.id,
|
||||||
|
senderEmail,
|
||||||
|
replySubject,
|
||||||
|
replyBody,
|
||||||
|
email.threadId,
|
||||||
|
{
|
||||||
|
storeId,
|
||||||
|
isAutomated: true,
|
||||||
|
trigger: "GMAIL_AUTO_REPLY",
|
||||||
|
originalMessageId: email.messageId,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Mark email as read after replying
|
||||||
|
await markEmailAsRead(session.user.id, email.id)
|
||||||
|
processed.push(email.id)
|
||||||
|
logger.info("Automated reply sent", {
|
||||||
|
userId: session.user.id,
|
||||||
|
to: senderEmail,
|
||||||
|
subject: email.subject
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
failed.push(email.id)
|
||||||
|
logger.error("Failed to send automated reply", {
|
||||||
|
userId: session.user.id,
|
||||||
|
to: senderEmail,
|
||||||
|
error: result.error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
failed.push(email.id)
|
||||||
|
logger.error("Error processing email", { emailId: email.id, error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
processed: processed.length,
|
||||||
|
failed: failed.length,
|
||||||
|
total: unreadEmails.length
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Email polling error", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to poll emails" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/app/api/integrations/gmail/status/route.ts
Normal file
31
src/app/api/integrations/gmail/status/route.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { handleApiError } from "@/lib/errors"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has a Gmail integration connected
|
||||||
|
const gmailIntegration = await prisma.gmailIntegration.findUnique({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if token is expired
|
||||||
|
const isExpired = gmailIntegration && gmailIntegration.expiresAt < new Date()
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
connected: !!gmailIntegration && !isExpired,
|
||||||
|
email: gmailIntegration?.email || null,
|
||||||
|
expiresAt: gmailIntegration?.expiresAt || null
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error, "gmail-status")
|
||||||
|
}
|
||||||
|
}
|
||||||
139
src/app/api/juno-engine/tickets/route.ts
Normal file
139
src/app/api/juno-engine/tickets/route.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { logger } from "@/lib/logger"
|
||||||
|
|
||||||
|
// Normalize subject by stripping common reply prefixes so we can group conversations
|
||||||
|
function normalizeSubject(subject: string | null): string {
|
||||||
|
if (!subject) return "No subject"
|
||||||
|
let s = subject.trim()
|
||||||
|
// Remove repeated "Re:" / "Fwd:" prefixes
|
||||||
|
// e.g. "Re: Re: Hello" -> "Hello"
|
||||||
|
// Case-insensitive, allow localized variants in future by trimming until no prefix matches
|
||||||
|
// eslint-disable-next-line no-constant-condition
|
||||||
|
while (true) {
|
||||||
|
const match = s.match(/^(re|fw|fwd):\s*/i)
|
||||||
|
if (!match) break
|
||||||
|
s = s.slice(match[0].length).trim()
|
||||||
|
}
|
||||||
|
return s || "No subject"
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the vendor's active store to scope tickets
|
||||||
|
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({ tickets: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const logs = await (prisma as any).emailLog.findMany({
|
||||||
|
where: {
|
||||||
|
storeId: activeStore.id,
|
||||||
|
isAutomated: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
sentAt: "desc",
|
||||||
|
},
|
||||||
|
take: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
type TicketMessage = {
|
||||||
|
id: string
|
||||||
|
subject: string
|
||||||
|
bodyPreview: string | null
|
||||||
|
status: string
|
||||||
|
trigger: string | null
|
||||||
|
sentAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Ticket = {
|
||||||
|
id: string
|
||||||
|
subject: string
|
||||||
|
rootSubject: string
|
||||||
|
customerEmail: string
|
||||||
|
status: string
|
||||||
|
updatedAt: string
|
||||||
|
messagesCount: number
|
||||||
|
lastMessageSnippet: string
|
||||||
|
messages: TicketMessage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketsMap = new Map<string, Ticket>()
|
||||||
|
|
||||||
|
for (const log of logs as Array<{
|
||||||
|
id: string
|
||||||
|
to: string
|
||||||
|
subject: string | null
|
||||||
|
bodyPreview: string | null
|
||||||
|
status: string
|
||||||
|
trigger: string | null
|
||||||
|
sentAt: Date
|
||||||
|
}>) {
|
||||||
|
const customerEmail = (log.to || "").toLowerCase()
|
||||||
|
const rootSubject = normalizeSubject(log.subject)
|
||||||
|
const key = `${customerEmail}::${rootSubject}`
|
||||||
|
|
||||||
|
const baseMessage: TicketMessage = {
|
||||||
|
id: log.id,
|
||||||
|
subject: log.subject || rootSubject,
|
||||||
|
bodyPreview: log.bodyPreview,
|
||||||
|
status: log.status,
|
||||||
|
trigger: log.trigger,
|
||||||
|
sentAt: log.sentAt.toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = ticketsMap.get(key)
|
||||||
|
if (!existing) {
|
||||||
|
ticketsMap.set(key, {
|
||||||
|
id: key,
|
||||||
|
subject: log.subject || rootSubject,
|
||||||
|
rootSubject,
|
||||||
|
customerEmail,
|
||||||
|
status: log.status,
|
||||||
|
updatedAt: log.sentAt.toISOString(),
|
||||||
|
messagesCount: 1,
|
||||||
|
lastMessageSnippet: log.bodyPreview || "",
|
||||||
|
messages: [baseMessage],
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
existing.messages.push(baseMessage)
|
||||||
|
existing.messagesCount += 1
|
||||||
|
// The logs are ordered newest-first, so the first seen message is the latest
|
||||||
|
// Keep lastMessageSnippet / updatedAt from the first entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tickets = Array.from(ticketsMap.values())
|
||||||
|
|
||||||
|
return NextResponse.json({ tickets })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to load vendor tickets from email logs", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to load tickets", tickets: [] },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
94
src/app/api/notifications/[notificationId]/route.ts
Normal file
94
src/app/api/notifications/[notificationId]/route.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/notifications/[notificationId]
|
||||||
|
* Mark a specific notification as read
|
||||||
|
*/
|
||||||
|
export async function PUT(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ notificationId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { notificationId } = await params
|
||||||
|
|
||||||
|
// Verify notification belongs to user
|
||||||
|
const notification = await prisma.notification.findFirst({
|
||||||
|
where: {
|
||||||
|
id: notificationId,
|
||||||
|
userId: session.user.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!notification) {
|
||||||
|
return NextResponse.json({ message: "Notification not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as read
|
||||||
|
await prisma.notification.update({
|
||||||
|
where: { id: notificationId },
|
||||||
|
data: {
|
||||||
|
read: true,
|
||||||
|
readAt: new Date()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ message: "Notification marked as read" })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating notification:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to update notification" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/notifications/[notificationId]
|
||||||
|
* Delete a notification
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ notificationId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { notificationId } = await params
|
||||||
|
|
||||||
|
// Verify notification belongs to user
|
||||||
|
const notification = await prisma.notification.findFirst({
|
||||||
|
where: {
|
||||||
|
id: notificationId,
|
||||||
|
userId: session.user.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!notification) {
|
||||||
|
return NextResponse.json({ message: "Notification not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.notification.delete({
|
||||||
|
where: { id: notificationId }
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ message: "Notification deleted" })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting notification:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to delete notification" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/app/api/notifications/route.ts
Normal file
110
src/app/api/notifications/route.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/notifications
|
||||||
|
* Get all notifications for the authenticated user
|
||||||
|
*/
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const unreadOnly = searchParams.get("unread") === "true"
|
||||||
|
const limit = parseInt(searchParams.get("limit") || "50")
|
||||||
|
|
||||||
|
const notifications = await prisma.notification.findMany({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
...(unreadOnly && { read: false })
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc"
|
||||||
|
},
|
||||||
|
take: limit
|
||||||
|
})
|
||||||
|
|
||||||
|
const unreadCount = await prisma.notification.count({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
read: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
notifications,
|
||||||
|
unreadCount
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching notifications:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to fetch notifications" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/notifications
|
||||||
|
* Mark notifications as read
|
||||||
|
*/
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const { notificationIds, markAllAsRead } = body
|
||||||
|
|
||||||
|
if (markAllAsRead) {
|
||||||
|
// Mark all notifications as read for this user
|
||||||
|
await prisma.notification.updateMany({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
read: false
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
read: true,
|
||||||
|
readAt: new Date()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ message: "All notifications marked as read" })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notificationIds && Array.isArray(notificationIds) && notificationIds.length > 0) {
|
||||||
|
// Mark specific notifications as read
|
||||||
|
await prisma.notification.updateMany({
|
||||||
|
where: {
|
||||||
|
id: { in: notificationIds },
|
||||||
|
userId: session.user.id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
read: true,
|
||||||
|
readAt: new Date()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ message: "Notifications marked as read" })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Invalid request. Provide notificationIds array or markAllAsRead: true" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating notifications:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to update notifications" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/app/api/onboarding/check/route.ts
Normal file
26
src/app/api/onboarding/check/route.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user.role !== "VENDOR") {
|
||||||
|
return NextResponse.json({ complete: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await prisma.store.findFirst({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
complete: !!store?.onboardingComplete
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ message: "Internal server error" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
116
src/app/api/onboarding/complete/route.ts
Normal file
116
src/app/api/onboarding/complete/route.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { logger } from "@/lib/logger"
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const {
|
||||||
|
businessName,
|
||||||
|
businessEmail,
|
||||||
|
country,
|
||||||
|
timezone,
|
||||||
|
storeName,
|
||||||
|
domain,
|
||||||
|
apiKey,
|
||||||
|
apiSecret,
|
||||||
|
accessToken
|
||||||
|
} = body
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!businessName || !domain || !accessToken) {
|
||||||
|
return NextResponse.json({ message: "Missing required onboarding data." }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean domain: remove protocol, trailing slashes, and trim whitespace
|
||||||
|
const cleanDomain = domain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim()
|
||||||
|
|
||||||
|
// Multi-store: find store by user + domain, then update or create
|
||||||
|
let store = await prisma.store.findFirst({
|
||||||
|
where: { userId: session.user.id, shopifyDomain: cleanDomain },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (store) {
|
||||||
|
store = await prisma.store.update({
|
||||||
|
where: { id: store.id },
|
||||||
|
data: {
|
||||||
|
businessName,
|
||||||
|
email: businessEmail ?? store.email,
|
||||||
|
country: country ?? store.country,
|
||||||
|
timezone: timezone ?? store.timezone,
|
||||||
|
shopifyStoreName: storeName ?? store.shopifyStoreName,
|
||||||
|
shopifyDomain: cleanDomain,
|
||||||
|
shopifyApiKey: apiKey ?? store.shopifyApiKey,
|
||||||
|
shopifyApiSecret: apiSecret ?? store.shopifyApiSecret,
|
||||||
|
shopifyAccessToken: accessToken,
|
||||||
|
onboardingComplete: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const existingCount = await prisma.store.count({ where: { userId: session.user.id } })
|
||||||
|
store = await prisma.store.create({
|
||||||
|
data: {
|
||||||
|
userId: session.user.id,
|
||||||
|
businessName,
|
||||||
|
email: businessEmail || session.user.email || "",
|
||||||
|
country: country || "US",
|
||||||
|
timezone: timezone || "UTC",
|
||||||
|
shopifyStoreName: storeName || "",
|
||||||
|
shopifyDomain: cleanDomain,
|
||||||
|
shopifyApiKey: apiKey || "",
|
||||||
|
shopifyApiSecret: apiSecret || "",
|
||||||
|
shopifyAccessToken: accessToken,
|
||||||
|
onboardingComplete: true,
|
||||||
|
isActive: existingCount === 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (existingCount === 0) {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
data: { activeStoreId: store.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register Webhooks with Shopify
|
||||||
|
const appUrl = process.env.NEXTAUTH_URL || "https://your-tunnel-url.ngrok-free.app"
|
||||||
|
const webhookTopics = ["orders/create", "orders/updated", "products/create", "products/update", "products/delete"]
|
||||||
|
|
||||||
|
for (const topic of webhookTopics) {
|
||||||
|
try {
|
||||||
|
await fetch(`https://${cleanDomain}/admin/api/2024-01/webhooks.json`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"X-Shopify-Access-Token": accessToken,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
webhook: {
|
||||||
|
topic,
|
||||||
|
address: `${appUrl}/api/webhooks/shopify`,
|
||||||
|
format: "json"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
logger.info(`Registered Shopify webhook: ${topic}`)
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Failed to register webhook ${topic}`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Onboarding completed and automation registered!",
|
||||||
|
store
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Onboarding completion error:", error)
|
||||||
|
return NextResponse.json({ message: "Internal server error while saving data." }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
132
src/app/api/onboarding/shopify/route.ts
Normal file
132
src/app/api/onboarding/shopify/route.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { logger } from "@/lib/logger"
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
businessName,
|
||||||
|
storeName,
|
||||||
|
domain,
|
||||||
|
apiKey,
|
||||||
|
apiSecret,
|
||||||
|
accessToken
|
||||||
|
} = await req.json()
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if (!businessName || !storeName || !domain || !apiKey || !apiSecret || !accessToken) {
|
||||||
|
return NextResponse.json({ message: "Missing required fields" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-store: create a new store; set as active if first store
|
||||||
|
const existingCount = await prisma.store.count({ where: { userId: session.user.id } })
|
||||||
|
const isFirstStore = existingCount === 0
|
||||||
|
|
||||||
|
// Clean domain: remove protocol, trailing slashes, and trim whitespace
|
||||||
|
const cleanDomain = domain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim()
|
||||||
|
|
||||||
|
const store = await prisma.store.create({
|
||||||
|
data: {
|
||||||
|
userId: session.user.id,
|
||||||
|
businessName,
|
||||||
|
email: session.user.email || "",
|
||||||
|
timezone: "UTC",
|
||||||
|
country: "US",
|
||||||
|
shopifyStoreName: storeName,
|
||||||
|
shopifyDomain: cleanDomain,
|
||||||
|
shopifyApiKey: apiKey,
|
||||||
|
shopifyApiSecret: apiSecret,
|
||||||
|
shopifyAccessToken: accessToken,
|
||||||
|
onboardingComplete: true,
|
||||||
|
isActive: isFirstStore,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isFirstStore) {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
data: { activeStoreId: store.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register webhooks for the new store
|
||||||
|
const appUrl = process.env.NEXTAUTH_URL || "https://your-tunnel-url.ngrok-free.app"
|
||||||
|
const webhookTopics = ["orders/create", "orders/updated", "products/create", "products/update", "products/delete"]
|
||||||
|
const webhookAddress = `${appUrl}/api/webhooks/shopify`
|
||||||
|
|
||||||
|
// First, check existing webhooks to avoid duplicates
|
||||||
|
let existingWebhooks: Array<{ topic: string; id: number; address: string }> = []
|
||||||
|
try {
|
||||||
|
const listResponse = await fetch(`https://${cleanDomain}/admin/api/2024-01/webhooks.json`, {
|
||||||
|
headers: {
|
||||||
|
"X-Shopify-Access-Token": accessToken,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (listResponse.ok) {
|
||||||
|
const webhooksData = await listResponse.json()
|
||||||
|
existingWebhooks = (webhooksData.webhooks || []).map((wh: { topic: string; id: number; address: string }) => ({
|
||||||
|
topic: wh.topic,
|
||||||
|
id: wh.id,
|
||||||
|
address: wh.address
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Could not fetch existing webhooks for store ${store.id}`, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const topic of webhookTopics) {
|
||||||
|
try {
|
||||||
|
// Check if webhook already exists with same address
|
||||||
|
const existingWebhook = existingWebhooks.find(
|
||||||
|
(wh) => wh.topic === topic && wh.address === webhookAddress
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existingWebhook) {
|
||||||
|
logger.info(`Webhook ${topic} already exists for address ${webhookAddress}, skipping registration`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`https://${cleanDomain}/admin/api/2024-01/webhooks.json`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"X-Shopify-Access-Token": accessToken,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
webhook: {
|
||||||
|
topic,
|
||||||
|
address: webhookAddress,
|
||||||
|
format: "json"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
logger.info(`Registered webhook: ${topic} for store ${store.id}`)
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
// Handle "already taken" error gracefully - webhook exists and will work
|
||||||
|
if (errorData.errors?.address?.some((msg: string) => msg.includes("already been taken"))) {
|
||||||
|
logger.info(`Webhook ${topic} already registered for address ${webhookAddress}, continuing`)
|
||||||
|
} else {
|
||||||
|
logger.error(`Failed to register webhook ${topic}`, errorData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Error registering webhook ${topic}`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ message: "Store onboarded successfully", store }, { status: 200 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Onboarding error:", error)
|
||||||
|
return NextResponse.json({ message: "Internal server error" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
196
src/app/api/products/sync/[syncId]/route.ts
Normal file
196
src/app/api/products/sync/[syncId]/route.ts
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/products/sync/[syncId]
|
||||||
|
* Accept or reject a product sync request
|
||||||
|
*/
|
||||||
|
export async function PUT(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ syncId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { syncId } = await params
|
||||||
|
const body = await req.json()
|
||||||
|
const { action } = body // "accept" or "reject"
|
||||||
|
|
||||||
|
if (!action || !["accept", "reject"].includes(action)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Invalid action. Use 'accept' or 'reject'" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get supplier profile
|
||||||
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
|
where: { userId: session.user.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supplierProfile) {
|
||||||
|
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find sync request
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const productSync = await (prisma as any).productSync.findUnique({
|
||||||
|
where: { id: syncId },
|
||||||
|
include: {
|
||||||
|
store: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
businessName: true,
|
||||||
|
userId: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!productSync) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Product sync request not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify sync belongs to this supplier
|
||||||
|
if (productSync.supplierId !== supplierProfile.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Unauthorized - this sync request is not for you" },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify sync is pending
|
||||||
|
if (productSync.status !== "PENDING") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: `This sync request has already been ${productSync.status.toLowerCase()}` },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sync status
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const updateData: any = {
|
||||||
|
status: action === "accept" ? "ACCEPTED" : "REJECTED"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "accept") {
|
||||||
|
updateData.acceptedAt = new Date()
|
||||||
|
} else {
|
||||||
|
updateData.rejectedAt = new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await (prisma as any).productSync.update({
|
||||||
|
where: { id: syncId },
|
||||||
|
data: updateData
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create notification for vendor
|
||||||
|
try {
|
||||||
|
const { notifyProductSyncStatusChanged } = await import("@/lib/notifications")
|
||||||
|
await notifyProductSyncStatusChanged(
|
||||||
|
productSync.store.userId,
|
||||||
|
supplierProfile.companyName,
|
||||||
|
productSync.shopifyProductTitle,
|
||||||
|
action === "accept" ? "ACCEPTED" : "REJECTED",
|
||||||
|
productSync.id
|
||||||
|
)
|
||||||
|
} catch (notifError) {
|
||||||
|
console.error("Error creating notification:", notifError)
|
||||||
|
// Don't fail the request if notification fails
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: `Product sync request ${action === "accept" ? "accepted" : "rejected"} successfully`,
|
||||||
|
sync: {
|
||||||
|
...productSync,
|
||||||
|
status: action === "accept" ? "ACCEPTED" : "REJECTED"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating product sync:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to update product sync" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/products/sync/[syncId]
|
||||||
|
* Cancel/delete a product sync request
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ syncId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { syncId } = await params
|
||||||
|
|
||||||
|
// Find sync request
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const productSync = await (prisma as any).productSync.findUnique({
|
||||||
|
where: { id: syncId },
|
||||||
|
include: {
|
||||||
|
store: {
|
||||||
|
select: {
|
||||||
|
userId: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!productSync) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Product sync request not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user owns the sync (vendor who requested or supplier who received)
|
||||||
|
const isVendor = productSync.store.userId === session.user.id
|
||||||
|
const isSupplier = session.user.role === "SUPPLIER"
|
||||||
|
|
||||||
|
if (!isVendor && !isSupplier) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Unauthorized" },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only vendor can delete pending requests, supplier should reject instead
|
||||||
|
if (isSupplier && productSync.status === "PENDING") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Please reject the request instead of deleting it" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await (prisma as any).productSync.delete({
|
||||||
|
where: { id: syncId }
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ message: "Product sync request deleted successfully" })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting product sync:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to delete product sync" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
199
src/app/api/products/sync/route.ts
Normal file
199
src/app/api/products/sync/route.ts
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/products/sync
|
||||||
|
* Get product sync status for vendor's products
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "VENDOR") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get vendor's active store
|
||||||
|
const vendorStore = await prisma.store.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!vendorStore) {
|
||||||
|
return NextResponse.json({ syncs: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all product syncs for this store
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const syncs = await (prisma as any).productSync.findMany({
|
||||||
|
where: {
|
||||||
|
storeId: vendorStore.id
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
supplier: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
companyName: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ syncs })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching product syncs:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to fetch product syncs" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/products/sync
|
||||||
|
* Create a product sync request
|
||||||
|
*/
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "VENDOR") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const { shopifyProductId, shopifyProductTitle, shopifyProductData, supplierId } = body
|
||||||
|
|
||||||
|
if (!shopifyProductId || !supplierId || !shopifyProductTitle) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Product ID, title, and supplier ID are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get vendor's active store
|
||||||
|
const vendorStore = await prisma.store.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!vendorStore) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "No active store found" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify supplier exists
|
||||||
|
const supplier = await prisma.supplierProfile.findUnique({
|
||||||
|
where: { id: supplierId },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supplier) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Supplier not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if connection exists between vendor and supplier
|
||||||
|
const connection = await prisma.connection.findUnique({
|
||||||
|
where: {
|
||||||
|
storeId_supplierId: {
|
||||||
|
storeId: vendorStore.id,
|
||||||
|
supplierId: supplier.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!connection || connection.status !== "CONNECTED") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "You must be connected with this supplier before syncing products" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if sync already exists
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const existingSync = await (prisma as any).productSync.findUnique({
|
||||||
|
where: {
|
||||||
|
storeId_supplierId_shopifyProductId: {
|
||||||
|
storeId: vendorStore.id,
|
||||||
|
supplierId: supplier.id,
|
||||||
|
shopifyProductId: shopifyProductId.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingSync) {
|
||||||
|
if (existingSync.status === "ACCEPTED") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "This product is already synced with this supplier" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (existingSync.status === "PENDING") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "A sync request for this product is already pending" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// If rejected, allow creating a new request
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create sync request
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const productSync = await (prisma as any).productSync.create({
|
||||||
|
data: {
|
||||||
|
storeId: vendorStore.id,
|
||||||
|
supplierId: supplier.id,
|
||||||
|
shopifyProductId: shopifyProductId.toString(),
|
||||||
|
shopifyProductTitle,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
shopifyProductData: shopifyProductData ? (shopifyProductData as any) : undefined,
|
||||||
|
status: "PENDING",
|
||||||
|
requestedBy: session.user.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create notification for supplier
|
||||||
|
try {
|
||||||
|
const { notifyProductSyncRequested } = await import("@/lib/notifications")
|
||||||
|
await notifyProductSyncRequested(
|
||||||
|
supplier.user.id,
|
||||||
|
vendorStore.businessName,
|
||||||
|
shopifyProductTitle,
|
||||||
|
productSync.id
|
||||||
|
)
|
||||||
|
} catch (notifError) {
|
||||||
|
console.error("Error creating notification:", notifError)
|
||||||
|
// Don't fail the request if notification fails
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Product sync request sent successfully",
|
||||||
|
sync: productSync
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating product sync:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to create product sync request" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/app/api/settings/account/route.ts
Normal file
85
src/app/api/settings/account/route.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import bcrypt from "bcryptjs"
|
||||||
|
import { accountUpdateSchema } from "@/lib/validation"
|
||||||
|
|
||||||
|
export async function PUT(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const parseResult = accountUpdateSchema.safeParse(body)
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
const firstIssue = parseResult.error.issues[0]
|
||||||
|
const field = firstIssue?.path?.[0]
|
||||||
|
const message = firstIssue?.message || "Invalid account data."
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message, field },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { businessName, currentPassword, newPassword } = parseResult.data
|
||||||
|
|
||||||
|
// Update business name in active store
|
||||||
|
if (businessName) {
|
||||||
|
const activeStore = await prisma.store.findFirst({
|
||||||
|
where: { userId: session.user.id, isActive: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (activeStore) {
|
||||||
|
await prisma.store.update({
|
||||||
|
where: { id: activeStore.id },
|
||||||
|
data: { businessName }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// If no active store, update user name
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
data: { name: businessName }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update password if provided
|
||||||
|
if (newPassword && currentPassword) {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user || !user.password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Password change not available for this account" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await bcrypt.compare(currentPassword, user.password)
|
||||||
|
if (!isValid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Current password is incorrect" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(newPassword, 10)
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
data: { password: hashedPassword }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ message: "Account updated successfully" })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Account update error:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/app/api/settings/mfa/setup/route.ts
Normal file
71
src/app/api/settings/mfa/setup/route.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { generateSecret, generateURI } from "otplib"
|
||||||
|
import { toDataURL } from "qrcode"
|
||||||
|
import { encryptString } from "@/lib/encryption"
|
||||||
|
import { logger } from "@/lib/logger"
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ message: "User not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a new secret
|
||||||
|
const secret = generateSecret()
|
||||||
|
|
||||||
|
// Create service name and account name for the QR code
|
||||||
|
const serviceName = process.env.MFA_ISSUER_NAME || "JUNOHUB"
|
||||||
|
const accountName = user.email || user.name || "User"
|
||||||
|
|
||||||
|
// Generate TOTP URI
|
||||||
|
const otpAuthUrl = generateURI({
|
||||||
|
secret,
|
||||||
|
label: accountName,
|
||||||
|
issuer: serviceName
|
||||||
|
})
|
||||||
|
|
||||||
|
// Generate QR code as data URL
|
||||||
|
let qrCodeDataUrl: string
|
||||||
|
try {
|
||||||
|
qrCodeDataUrl = await toDataURL(otpAuthUrl)
|
||||||
|
} catch (qrError) {
|
||||||
|
logger.error("QR Code generation error", qrError)
|
||||||
|
throw new Error(`Failed to generate QR code: ${qrError instanceof Error ? qrError.message : "Unknown error"}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the secret temporarily (not enabled yet - will be enabled after verification)
|
||||||
|
// Encrypt the secret at rest
|
||||||
|
const encryptedSecret = encryptString(secret)
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`UPDATE "User" SET "mfaSecret" = $1 WHERE id = $2`,
|
||||||
|
encryptedSecret,
|
||||||
|
session.user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
secret,
|
||||||
|
qrCode: qrCodeDataUrl,
|
||||||
|
manualEntryKey: secret // For manual entry if QR code can't be scanned
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("MFA setup error", error)
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||||
|
const errorStack = error instanceof Error ? error.stack : undefined
|
||||||
|
logger.error("MFA setup error details", { errorMessage, errorStack })
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Internal server error", error: errorMessage },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/app/api/settings/mfa/status/route.ts
Normal file
40
src/app/api/settings/mfa/status/route.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { decryptString } from "@/lib/encryption"
|
||||||
|
import { logger } from "@/lib/logger"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use raw query to access MFA fields until Prisma client is fully regenerated
|
||||||
|
const result = await prisma.$queryRawUnsafe<Array<{ mfaEnabled: boolean | null; mfaSecret: string | null }>>(
|
||||||
|
`SELECT "mfaEnabled", "mfaSecret" FROM "User" WHERE id = $1`,
|
||||||
|
session.user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result || result.length === 0) {
|
||||||
|
return NextResponse.json({ message: "User not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = result[0]
|
||||||
|
const hasSecret = !!decryptString(user.mfaSecret)
|
||||||
|
return NextResponse.json({
|
||||||
|
enabled: user.mfaEnabled ?? false,
|
||||||
|
hasSecret
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("MFA status error", error)
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||||
|
const errorStack = error instanceof Error ? error.stack : undefined
|
||||||
|
logger.error("MFA status error details", { errorMessage, errorStack })
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Internal server error", error: errorMessage },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/app/api/settings/mfa/toggle/route.ts
Normal file
59
src/app/api/settings/mfa/toggle/route.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { decryptString } from "@/lib/encryption"
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { enabled } = await req.json()
|
||||||
|
|
||||||
|
// Use raw query to check mfaSecret until Prisma client is fully regenerated
|
||||||
|
const result = await prisma.$queryRawUnsafe<Array<{ mfaSecret: string | null }>>(
|
||||||
|
`SELECT "mfaSecret" FROM "User" WHERE id = $1`,
|
||||||
|
session.user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result || result.length === 0) {
|
||||||
|
return NextResponse.json({ message: "User not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedSecret = result[0].mfaSecret
|
||||||
|
const mfaSecret = decryptString(storedSecret)
|
||||||
|
if (enabled && !mfaSecret) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Please set up MFA first by generating a secret and verifying it." },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable MFA (can be done without verification)
|
||||||
|
if (!enabled) {
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`UPDATE "User" SET "mfaEnabled" = false WHERE id = $1`,
|
||||||
|
session.user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "MFA disabled successfully",
|
||||||
|
enabled: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabling requires verification (handled by verify endpoint)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Please verify your code first using the verify endpoint" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("MFA toggle error:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/app/api/settings/mfa/verify/route.ts
Normal file
83
src/app/api/settings/mfa/verify/route.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { verify } from "otplib"
|
||||||
|
import { decryptString } from "@/lib/encryption"
|
||||||
|
import { applyRateLimit } from "@/lib/rate-limit"
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { code } = await req.json()
|
||||||
|
|
||||||
|
// Rate limit MFA setup verification attempts per IP
|
||||||
|
const rate = await applyRateLimit(req, "settings:mfa-verify")
|
||||||
|
if (!rate.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Too many verification attempts. Please try again later." },
|
||||||
|
{ status: 429 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code || typeof code !== "string") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Verification code is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use raw query to access mfaSecret until Prisma client is fully regenerated
|
||||||
|
const userResult = await prisma.$queryRawUnsafe<Array<{ mfaSecret: string | null }>>(
|
||||||
|
`SELECT "mfaSecret" FROM "User" WHERE id = $1`,
|
||||||
|
session.user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!userResult || userResult.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "User not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedSecret = userResult[0].mfaSecret
|
||||||
|
const mfaSecret = decryptString(storedSecret)
|
||||||
|
if (!mfaSecret) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "MFA secret not found. Please set up MFA first." },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the TOTP code
|
||||||
|
const verifyResult = await verify({ token: code, secret: mfaSecret })
|
||||||
|
const isValid = verifyResult.valid ?? false
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Invalid verification code" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable MFA
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`UPDATE "User" SET "mfaEnabled" = true WHERE id = $1`,
|
||||||
|
session.user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "MFA enabled successfully",
|
||||||
|
enabled: true
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("MFA verify error:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/app/api/settings/sessions/[sessionId]/route.ts
Normal file
42
src/app/api/settings/sessions/[sessionId]/route.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ sessionId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sessionId } = await params
|
||||||
|
|
||||||
|
// Verify the session belongs to the user
|
||||||
|
const userSession = await prisma.session.findFirst({
|
||||||
|
where: {
|
||||||
|
id: sessionId,
|
||||||
|
userId: session.user.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!userSession) {
|
||||||
|
return NextResponse.json({ message: "Session not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the session
|
||||||
|
await prisma.session.delete({
|
||||||
|
where: { id: sessionId }
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ message: "Session revoked successfully" })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Revoke session error:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/app/api/settings/sessions/route.ts
Normal file
40
src/app/api/settings/sessions/route.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { cookies } from "next/headers"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current session token from cookies
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const sessionToken = cookieStore.get("authjs.session-token")?.value ||
|
||||||
|
cookieStore.get("__Secure-authjs.session-token")?.value
|
||||||
|
|
||||||
|
// Fetch all sessions for the user
|
||||||
|
const userSessions = await prisma.session.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
orderBy: { expires: "desc" }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Transform sessions to include additional info
|
||||||
|
const sessions = userSessions.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
sessionToken: s.sessionToken,
|
||||||
|
expires: s.expires.toISOString(),
|
||||||
|
isCurrent: s.sessionToken === sessionToken
|
||||||
|
}))
|
||||||
|
|
||||||
|
return NextResponse.json({ sessions })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fetch sessions error:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/app/api/shopify/customers/route.ts
Normal file
108
src/app/api/shopify/customers/route.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { isCacheFresh, getCachedCustomers, updateCustomerCache } from "@/lib/cache"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await prisma.store.findFirst({
|
||||||
|
where: { userId: session.user.id, isActive: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!store || !store.shopifyAccessToken || !store.shopifyDomain) {
|
||||||
|
return NextResponse.json({ message: "Shopify store not connected." }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean domain to remove any whitespace or invalid characters
|
||||||
|
const cleanDomain = store.shopifyDomain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim()
|
||||||
|
|
||||||
|
if (!cleanDomain) {
|
||||||
|
return NextResponse.json({ message: "Invalid Shopify domain configured." }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cache is fresh
|
||||||
|
const cacheFresh = await isCacheFresh(store.id, "customers")
|
||||||
|
|
||||||
|
if (cacheFresh) {
|
||||||
|
// Return cached data transformed to match Shopify format
|
||||||
|
const cachedCustomers = await getCachedCustomers(store.id)
|
||||||
|
const transformedCustomers = cachedCustomers.map(customer => ({
|
||||||
|
id: parseInt(customer.shopifyCustomerId),
|
||||||
|
email: customer.email,
|
||||||
|
first_name: customer.firstName,
|
||||||
|
last_name: customer.lastName,
|
||||||
|
orders_count: customer.ordersCount,
|
||||||
|
total_spent: customer.totalSpent,
|
||||||
|
phone: customer.phone,
|
||||||
|
tags: customer.tags,
|
||||||
|
created_at: customer.shopifyCreatedAt?.toISOString() || customer.createdAt.toISOString()
|
||||||
|
}))
|
||||||
|
return NextResponse.json({
|
||||||
|
customers: transformedCustomers,
|
||||||
|
cached: true,
|
||||||
|
count: transformedCustomers.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch customers from Shopify with timeout and limit
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 30000) // 30 second timeout
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://${cleanDomain}/admin/api/2024-01/customers.json?limit=50`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"X-Shopify-Access-Token": store.shopifyAccessToken,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
signal: controller.signal
|
||||||
|
}
|
||||||
|
)
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ message: "Unknown error" }))
|
||||||
|
console.error("Shopify API error:", { status: response.status, error: errorData, storeId: store.id })
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Failed to fetch customers from Shopify.",
|
||||||
|
details: errorData
|
||||||
|
}, { status: response.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
// Update cache in background
|
||||||
|
if (data.customers) {
|
||||||
|
await updateCustomerCache(store.id, data.customers).catch(err => {
|
||||||
|
console.error("Cache update error:", err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ...data, cached: false })
|
||||||
|
} catch (fetchError: unknown) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
if (fetchError instanceof Error && fetchError.name === "AbortError") {
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Request timeout. Shopify API took too long to respond.",
|
||||||
|
error: "TIMEOUT"
|
||||||
|
}, { status: 504 })
|
||||||
|
}
|
||||||
|
throw fetchError
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error"
|
||||||
|
console.error("Fetch customers error:", { message, error })
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Internal server error while fetching customers.",
|
||||||
|
error: message
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/app/api/shopify/inventory/route.ts
Normal file
110
src/app/api/shopify/inventory/route.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { isCacheFresh, getCachedProducts, updateProductCache } from "@/lib/cache"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await prisma.store.findFirst({
|
||||||
|
where: { userId: session.user.id, isActive: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!store || !store.shopifyAccessToken || !store.shopifyDomain) {
|
||||||
|
return NextResponse.json({ message: "Shopify store not connected." }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean domain to remove any whitespace or invalid characters
|
||||||
|
const cleanDomain = store.shopifyDomain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim()
|
||||||
|
|
||||||
|
if (!cleanDomain) {
|
||||||
|
return NextResponse.json({ message: "Invalid Shopify domain configured." }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cache is fresh
|
||||||
|
const cacheFresh = await isCacheFresh(store.id, "products")
|
||||||
|
|
||||||
|
if (cacheFresh) {
|
||||||
|
// Return cached data transformed to match Shopify format
|
||||||
|
const cachedProducts = await getCachedProducts(store.id)
|
||||||
|
const transformedProducts = cachedProducts.map(product => {
|
||||||
|
const images = (product.images as { src: string }[]) || []
|
||||||
|
return {
|
||||||
|
id: parseInt(product.shopifyProductId),
|
||||||
|
title: product.title,
|
||||||
|
vendor: product.vendor,
|
||||||
|
product_type: product.productType,
|
||||||
|
status: product.status,
|
||||||
|
variants: product.variants,
|
||||||
|
images: images,
|
||||||
|
image: images.length > 0 ? { src: images[0].src } : null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return NextResponse.json({
|
||||||
|
products: transformedProducts,
|
||||||
|
cached: true,
|
||||||
|
count: transformedProducts.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fresh data from Shopify with timeout
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 30000) // 30 second timeout
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://${cleanDomain}/admin/api/2024-01/products.json?limit=250`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"X-Shopify-Access-Token": store.shopifyAccessToken,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
signal: controller.signal
|
||||||
|
}
|
||||||
|
)
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ message: "Unknown error" }))
|
||||||
|
console.error("Shopify API error:", { status: response.status, error: errorData, storeId: store.id })
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Failed to fetch products from Shopify.",
|
||||||
|
details: errorData
|
||||||
|
}, { status: response.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
// Update cache in background
|
||||||
|
if (data.products) {
|
||||||
|
await updateProductCache(store.id, data.products).catch(err => {
|
||||||
|
console.error("Cache update error:", err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ...data, cached: false })
|
||||||
|
} catch (fetchError: unknown) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
if (fetchError instanceof Error && fetchError.name === "AbortError") {
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Request timeout. Shopify API took too long to respond.",
|
||||||
|
error: "TIMEOUT"
|
||||||
|
}, { status: 504 })
|
||||||
|
}
|
||||||
|
throw fetchError
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error"
|
||||||
|
console.error("Fetch inventory error:", { message, error, stack: error instanceof Error ? error.stack : undefined })
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Internal server error while fetching inventory.",
|
||||||
|
error: message
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/app/api/shopify/orders/[orderId]/route.ts
Normal file
74
src/app/api/shopify/orders/[orderId]/route.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { handleApiError } from "@/lib/errors"
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ orderId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orderId } = await params
|
||||||
|
|
||||||
|
const store = await prisma.store.findFirst({
|
||||||
|
where: { userId: session.user.id, isActive: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!store || !store.shopifyAccessToken || !store.shopifyDomain) {
|
||||||
|
return NextResponse.json({ message: "Shopify store not connected." }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanDomain = store.shopifyDomain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim()
|
||||||
|
|
||||||
|
if (!cleanDomain) {
|
||||||
|
return NextResponse.json({ message: "Invalid Shopify domain configured." }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch order from Shopify
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 30000)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://${cleanDomain}/admin/api/2024-01/orders/${orderId}.json`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"X-Shopify-Access-Token": store.shopifyAccessToken,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
signal: controller.signal
|
||||||
|
}
|
||||||
|
)
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ message: "Unknown error" }))
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Failed to fetch order from Shopify.",
|
||||||
|
details: errorData
|
||||||
|
}, { status: response.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json({ order: data.order })
|
||||||
|
} catch (fetchError: unknown) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
if (fetchError instanceof Error && fetchError.name === "AbortError") {
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Request timeout.",
|
||||||
|
error: "TIMEOUT"
|
||||||
|
}, { status: 504 })
|
||||||
|
}
|
||||||
|
throw fetchError
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return handleApiError(error, "fetch-order")
|
||||||
|
}
|
||||||
|
}
|
||||||
109
src/app/api/shopify/orders/route.ts
Normal file
109
src/app/api/shopify/orders/route.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { isCacheFresh, getCachedOrders, updateOrderCache } from "@/lib/cache"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await prisma.store.findFirst({
|
||||||
|
where: { userId: session.user.id, isActive: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!store || !store.shopifyAccessToken || !store.shopifyDomain) {
|
||||||
|
return NextResponse.json({ message: "Shopify store not connected." }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean domain to remove any whitespace or invalid characters
|
||||||
|
const cleanDomain = store.shopifyDomain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim()
|
||||||
|
|
||||||
|
if (!cleanDomain) {
|
||||||
|
return NextResponse.json({ message: "Invalid Shopify domain configured." }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cache is fresh
|
||||||
|
const cacheFresh = await isCacheFresh(store.id, "orders")
|
||||||
|
|
||||||
|
if (cacheFresh) {
|
||||||
|
// Return cached data transformed to match Shopify format
|
||||||
|
const cachedOrders = await getCachedOrders(store.id)
|
||||||
|
const transformedOrders = cachedOrders.map(order => ({
|
||||||
|
id: parseInt(order.shopifyOrderId),
|
||||||
|
name: order.orderNumber,
|
||||||
|
email: order.email,
|
||||||
|
created_at: order.shopifyCreatedAt?.toISOString() || order.createdAt.toISOString(),
|
||||||
|
total_price: order.totalPrice,
|
||||||
|
currency: order.currency,
|
||||||
|
financial_status: order.financialStatus,
|
||||||
|
fulfillment_status: order.fulfillmentStatus,
|
||||||
|
customer: order.customer,
|
||||||
|
line_items: order.lineItems
|
||||||
|
}))
|
||||||
|
return NextResponse.json({
|
||||||
|
orders: transformedOrders,
|
||||||
|
cached: true,
|
||||||
|
count: transformedOrders.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch orders from Shopify with timeout and limit
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 30000) // 30 second timeout
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://${cleanDomain}/admin/api/2024-01/orders.json?status=any&limit=50`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"X-Shopify-Access-Token": store.shopifyAccessToken,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
signal: controller.signal
|
||||||
|
}
|
||||||
|
)
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ message: "Unknown error" }))
|
||||||
|
console.error("Shopify API error:", { status: response.status, error: errorData, storeId: store.id })
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Failed to fetch orders from Shopify.",
|
||||||
|
details: errorData
|
||||||
|
}, { status: response.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
// Update cache in background
|
||||||
|
if (data.orders) {
|
||||||
|
await updateOrderCache(store.id, data.orders).catch(err => {
|
||||||
|
console.error("Cache update error:", err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ...data, cached: false })
|
||||||
|
} catch (fetchError: unknown) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
if (fetchError instanceof Error && fetchError.name === "AbortError") {
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Request timeout. Shopify API took too long to respond.",
|
||||||
|
error: "TIMEOUT"
|
||||||
|
}, { status: 504 })
|
||||||
|
}
|
||||||
|
throw fetchError
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error"
|
||||||
|
console.error("Fetch orders error:", { message, error, stack: error instanceof Error ? error.stack : undefined })
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Internal server error while fetching orders.",
|
||||||
|
error: message
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/app/api/shopify/validate/route.ts
Normal file
62
src/app/api/shopify/validate/route.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
const { domain, accessToken } = body
|
||||||
|
|
||||||
|
if (!domain || !accessToken) {
|
||||||
|
return NextResponse.json({ message: "Store domain and access token are required." }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean domain: remove protocol, trailing slashes, and trim whitespace
|
||||||
|
const cleanDomain = domain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim()
|
||||||
|
|
||||||
|
// Create an AbortController for timeout
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 10000)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Shopify Shop API endpoint
|
||||||
|
const response = await fetch(`https://${cleanDomain}/admin/api/2024-01/shop.json`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"X-Shopify-Access-Token": accessToken,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Invalid credentials. Please verify your domain and access token.",
|
||||||
|
details: errorData
|
||||||
|
}, { status: response.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Validation successful!",
|
||||||
|
shop: data.shop
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (fetchError: unknown) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
if (fetchError instanceof Error && fetchError.name === "AbortError") {
|
||||||
|
return NextResponse.json({ message: "Connection to Shopify timed out. Please check the domain." }, { status: 408 })
|
||||||
|
}
|
||||||
|
throw fetchError
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error"
|
||||||
|
console.error("Shopify validation error:", error)
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Failed to connect to Shopify. Verify the domain is correct.",
|
||||||
|
error: message
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
298
src/app/api/stores/[storeId]/kb/documents/route.ts
Normal file
298
src/app/api/stores/[storeId]/kb/documents/route.ts
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { logger } from "@/lib/logger"
|
||||||
|
import { processRawDocument } from "@/lib/kb/documentProcessing"
|
||||||
|
import { indexDocumentChunks } from "@/lib/kb/indexing"
|
||||||
|
import path from "path"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import { randomUUID } from "crypto"
|
||||||
|
|
||||||
|
const UPLOAD_DIR = process.env.KB_UPLOAD_DIR || "uploads/kb"
|
||||||
|
|
||||||
|
async function ensureUploadDir() {
|
||||||
|
const absolute = path.resolve(UPLOAD_DIR)
|
||||||
|
await fs.mkdir(absolute, { recursive: true })
|
||||||
|
return absolute
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ storeId: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storeId } = await params
|
||||||
|
|
||||||
|
const store = await prisma.store.findFirst({
|
||||||
|
where: {
|
||||||
|
id: storeId,
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
return NextResponse.json({ message: "Store not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData()
|
||||||
|
const file = formData.get("file")
|
||||||
|
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Missing file field 'file'" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = file.type || "application/octet-stream"
|
||||||
|
const originalName = file.name || "document"
|
||||||
|
|
||||||
|
if (
|
||||||
|
![
|
||||||
|
"application/pdf",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"application/msword",
|
||||||
|
].includes(mimeType)
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Only PDF and DOCX files are supported" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadDir = await ensureUploadDir()
|
||||||
|
const id = randomUUID()
|
||||||
|
const fileNameOnDisk = `${id}-${originalName}`
|
||||||
|
const storagePath = path.join(uploadDir, fileNameOnDisk)
|
||||||
|
|
||||||
|
const arrayBuffer = await file.arrayBuffer()
|
||||||
|
await fs.writeFile(storagePath, Buffer.from(arrayBuffer))
|
||||||
|
|
||||||
|
const sizeBytes = Buffer.byteLength(Buffer.from(arrayBuffer))
|
||||||
|
|
||||||
|
// Insert document via raw SQL to avoid relying on generated model delegates
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`
|
||||||
|
INSERT INTO "KnowledgeDocument"
|
||||||
|
("id", "storeId", "title", "fileName", "mimeType", "sizeBytes", "status", "errorMessage", "storagePath", "createdAt", "updatedAt")
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, 'PROCESSING', NULL, $7, NOW(), NOW())
|
||||||
|
`,
|
||||||
|
id,
|
||||||
|
store.id,
|
||||||
|
originalName,
|
||||||
|
originalName,
|
||||||
|
mimeType,
|
||||||
|
sizeBytes,
|
||||||
|
storagePath,
|
||||||
|
)
|
||||||
|
|
||||||
|
const [document] = (await prisma.$queryRawUnsafe<
|
||||||
|
{
|
||||||
|
id: string
|
||||||
|
storeId: string
|
||||||
|
title: string
|
||||||
|
fileName: string
|
||||||
|
mimeType: string
|
||||||
|
sizeBytes: number
|
||||||
|
status: string
|
||||||
|
storagePath: string
|
||||||
|
}[]
|
||||||
|
>(
|
||||||
|
`
|
||||||
|
SELECT "id", "storeId", "title", "fileName", "mimeType", "sizeBytes", "status", "storagePath"
|
||||||
|
FROM "KnowledgeDocument"
|
||||||
|
WHERE "id" = $1
|
||||||
|
`,
|
||||||
|
id,
|
||||||
|
)) ?? []
|
||||||
|
|
||||||
|
// Process and index inline for now; can be moved to a background job later
|
||||||
|
try {
|
||||||
|
const chunks = await processRawDocument({
|
||||||
|
id: document.id,
|
||||||
|
storeId: document.storeId,
|
||||||
|
storagePath: document.storagePath,
|
||||||
|
mimeType: document.mimeType,
|
||||||
|
})
|
||||||
|
|
||||||
|
await indexDocumentChunks(chunks)
|
||||||
|
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`
|
||||||
|
UPDATE "KnowledgeDocument"
|
||||||
|
SET "status" = 'READY',
|
||||||
|
"errorMessage" = NULL,
|
||||||
|
"updatedAt" = NOW()
|
||||||
|
WHERE "id" = $1
|
||||||
|
`,
|
||||||
|
document.id,
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to process knowledge document", error)
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`
|
||||||
|
UPDATE "KnowledgeDocument"
|
||||||
|
SET "status" = 'FAILED',
|
||||||
|
"errorMessage" = $2,
|
||||||
|
"updatedAt" = NOW()
|
||||||
|
WHERE "id" = $1
|
||||||
|
`,
|
||||||
|
document.id,
|
||||||
|
error instanceof Error ? error.message : "Unknown error",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
document: {
|
||||||
|
id: document.id,
|
||||||
|
title: document.title,
|
||||||
|
status: document.status,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error uploading knowledge base document", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to upload document" },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ storeId: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storeId } = await params
|
||||||
|
|
||||||
|
const store = await prisma.store.findFirst({
|
||||||
|
where: {
|
||||||
|
id: storeId,
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
return NextResponse.json({ message: "Store not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const documents = await prisma.$queryRawUnsafe<
|
||||||
|
{
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
fileName: string
|
||||||
|
mimeType: string
|
||||||
|
sizeBytes: number
|
||||||
|
status: string
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}[]
|
||||||
|
>(
|
||||||
|
`
|
||||||
|
SELECT "id", "title", "fileName", "mimeType", "sizeBytes", "status", "createdAt", "updatedAt"
|
||||||
|
FROM "KnowledgeDocument"
|
||||||
|
WHERE "storeId" = $1
|
||||||
|
ORDER BY "createdAt" DESC
|
||||||
|
`,
|
||||||
|
store.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ documents })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error listing knowledge base documents", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to list documents" },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ storeId: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storeId } = await params
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const documentId = searchParams.get("id")
|
||||||
|
|
||||||
|
if (!documentId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Document ID is required" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await prisma.store.findFirst({
|
||||||
|
where: {
|
||||||
|
id: storeId,
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
return NextResponse.json({ message: "Store not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the document belongs to this store
|
||||||
|
const [doc] = (await prisma.$queryRawUnsafe<
|
||||||
|
{ id: string; storeId: string }[]
|
||||||
|
>(
|
||||||
|
`
|
||||||
|
SELECT "id", "storeId"
|
||||||
|
FROM "KnowledgeDocument"
|
||||||
|
WHERE "id" = $1 AND "storeId" = $2
|
||||||
|
`,
|
||||||
|
documentId,
|
||||||
|
store.id,
|
||||||
|
)) ?? []
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Document not found" },
|
||||||
|
{ status: 404 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete associated chunks first, then the document
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`DELETE FROM "KnowledgeChunk" WHERE "documentId" = $1`,
|
||||||
|
documentId,
|
||||||
|
)
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`DELETE FROM "KnowledgeDocument" WHERE "id" = $1`,
|
||||||
|
documentId,
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error deleting knowledge base document", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to delete document" },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
154
src/app/api/stores/[storeId]/kb/faqs/import/route.ts
Normal file
154
src/app/api/stores/[storeId]/kb/faqs/import/route.ts
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { logger } from "@/lib/logger"
|
||||||
|
import { embedBatch } from "@/lib/kb/geminiEmbeddings"
|
||||||
|
|
||||||
|
type FaqImportItem = {
|
||||||
|
question: string
|
||||||
|
answer: string
|
||||||
|
tags?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ storeId: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storeId } = await params
|
||||||
|
|
||||||
|
const store = await prisma.store.findFirst({
|
||||||
|
where: {
|
||||||
|
id: storeId,
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
return NextResponse.json({ message: "Store not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let items: FaqImportItem[] = []
|
||||||
|
|
||||||
|
const contentType = request.headers.get("content-type") || ""
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
|
const body = await request.json()
|
||||||
|
if (!Array.isArray(body)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "JSON body must be an array of FAQs" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items = body as FaqImportItem[]
|
||||||
|
} else if (contentType.includes("multipart/form-data")) {
|
||||||
|
const formData = await request.formData()
|
||||||
|
const file = formData.get("file")
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Missing file field 'file'" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const text = await file.text()
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text)
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "JSON must be an array of FAQs" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items = parsed as FaqImportItem[]
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Invalid JSON in uploaded file" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Unsupported content type" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const validItems = items.filter(
|
||||||
|
(item) => item.question && item.answer,
|
||||||
|
) as FaqImportItem[]
|
||||||
|
|
||||||
|
if (!validItems.length) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "No valid FAQs to import" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdFaqs = await prisma.$transaction(
|
||||||
|
validItems.map((item) =>
|
||||||
|
prisma.faqItem.create({
|
||||||
|
data: {
|
||||||
|
storeId: store.id,
|
||||||
|
question: item.question,
|
||||||
|
answer: item.answer,
|
||||||
|
tags: item.tags ?? undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const texts = createdFaqs.map(
|
||||||
|
(faq) => `${faq.question}\n${faq.answer}`,
|
||||||
|
)
|
||||||
|
const embeddings = await embedBatch(texts)
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
createdFaqs.map((faq, index) => {
|
||||||
|
const embedding = embeddings[index]
|
||||||
|
if (!embedding || !embedding.length) return Promise.resolve()
|
||||||
|
|
||||||
|
return prisma.$executeRawUnsafe(
|
||||||
|
`
|
||||||
|
INSERT INTO "KnowledgeChunk"
|
||||||
|
("id", "storeId", "faqItemId", "sourceType", "content", "metadata", "embedding", "createdAt", "updatedAt")
|
||||||
|
VALUES (
|
||||||
|
gen_random_uuid()::text,
|
||||||
|
$1,
|
||||||
|
$2,
|
||||||
|
'FAQ',
|
||||||
|
$3,
|
||||||
|
$4,
|
||||||
|
'[${embedding.join(",")}]',
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
faq.storeId,
|
||||||
|
faq.id,
|
||||||
|
`${faq.question}\n${faq.answer}`,
|
||||||
|
{
|
||||||
|
question: faq.question,
|
||||||
|
answer: faq.answer,
|
||||||
|
} as unknown as object,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ imported: createdFaqs.length },
|
||||||
|
{ status: 201 },
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error importing FAQs", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to import FAQs" },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
131
src/app/api/stores/[storeId]/kb/faqs/route.ts
Normal file
131
src/app/api/stores/[storeId]/kb/faqs/route.ts
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { logger } from "@/lib/logger"
|
||||||
|
import { embedBatch } from "@/lib/kb/geminiEmbeddings"
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ storeId: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storeId } = await params
|
||||||
|
|
||||||
|
const store = await prisma.store.findFirst({
|
||||||
|
where: {
|
||||||
|
id: storeId,
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
return NextResponse.json({ message: "Store not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const faqs = await prisma.faqItem.findMany({
|
||||||
|
where: { storeId: store.id },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ faqs })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error listing FAQs", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to list FAQs" },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ storeId: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storeId } = await params
|
||||||
|
|
||||||
|
const store = await prisma.store.findFirst({
|
||||||
|
where: {
|
||||||
|
id: storeId,
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
return NextResponse.json({ message: "Store not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const { question, answer, tags } = body as {
|
||||||
|
question?: string
|
||||||
|
answer?: string
|
||||||
|
tags?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!question || !answer) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Question and answer are required" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const faq = await prisma.faqItem.create({
|
||||||
|
data: {
|
||||||
|
storeId: store.id,
|
||||||
|
question,
|
||||||
|
answer,
|
||||||
|
tags: tags ?? undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const [embedding] = await embedBatch([`${question}\n${answer}`])
|
||||||
|
|
||||||
|
if (embedding && embedding.length) {
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`
|
||||||
|
INSERT INTO "KnowledgeChunk"
|
||||||
|
("id", "storeId", "faqItemId", "sourceType", "content", "metadata", "embedding", "createdAt", "updatedAt")
|
||||||
|
VALUES (
|
||||||
|
gen_random_uuid()::text,
|
||||||
|
$1,
|
||||||
|
$2,
|
||||||
|
'FAQ',
|
||||||
|
$3,
|
||||||
|
$4,
|
||||||
|
'[${embedding.join(",")}]',
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
faq.storeId,
|
||||||
|
faq.id,
|
||||||
|
`${question}\n${answer}`,
|
||||||
|
{
|
||||||
|
question,
|
||||||
|
answer,
|
||||||
|
} as unknown as object,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ faq }, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error creating FAQ", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to create FAQ" },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
224
src/app/api/stores/[storeId]/kb/query/route.ts
Normal file
224
src/app/api/stores/[storeId]/kb/query/route.ts
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { logger } from "@/lib/logger"
|
||||||
|
import { embedBatch } from "@/lib/kb/geminiEmbeddings"
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
query?: string
|
||||||
|
topK?: number
|
||||||
|
includeFaq?: boolean
|
||||||
|
includeDocuments?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ storeId: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storeId } = await params
|
||||||
|
|
||||||
|
const store = await prisma.store.findFirst({
|
||||||
|
where: {
|
||||||
|
id: storeId,
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
return NextResponse.json({ message: "Store not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json().catch(() => ({}))) as QueryBody
|
||||||
|
const question = body.query?.trim()
|
||||||
|
if (!question) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Query is required" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const topK = Math.min(Math.max(body.topK ?? 8, 1), 20)
|
||||||
|
const includeFaq = body.includeFaq ?? true
|
||||||
|
const includeDocuments = body.includeDocuments ?? true
|
||||||
|
|
||||||
|
if (!includeFaq && !includeDocuments) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "At least one of FAQs or Documents must be included" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [embedding] = await embedBatch([question])
|
||||||
|
if (!embedding || !embedding.length) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to generate embedding for query" },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: string[] = ['"storeId" = $1']
|
||||||
|
if (includeFaq && !includeDocuments) {
|
||||||
|
filters.push(`"sourceType" = 'FAQ'`)
|
||||||
|
} else if (!includeFaq && includeDocuments) {
|
||||||
|
filters.push(`"sourceType" = 'DOCUMENT'`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = filters.length ? `WHERE ${filters.join(" AND ")}` : ""
|
||||||
|
|
||||||
|
const chunks = await prisma.$queryRawUnsafe<
|
||||||
|
{
|
||||||
|
id: string
|
||||||
|
content: string
|
||||||
|
sourceType: "DOCUMENT" | "FAQ"
|
||||||
|
documentId: string | null
|
||||||
|
faqItemId: string | null
|
||||||
|
metadata: unknown
|
||||||
|
similarity: number
|
||||||
|
}[]
|
||||||
|
>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
"id",
|
||||||
|
"content",
|
||||||
|
"sourceType",
|
||||||
|
"documentId",
|
||||||
|
"faqItemId",
|
||||||
|
"metadata",
|
||||||
|
1 - ("embedding" <=> '[${embedding.join(",")}]') AS similarity
|
||||||
|
FROM "KnowledgeChunk"
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY "embedding" <=> '[${embedding.join(",")}]'
|
||||||
|
LIMIT ${topK}
|
||||||
|
`,
|
||||||
|
store.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!chunks.length) {
|
||||||
|
return NextResponse.json({
|
||||||
|
answer:
|
||||||
|
"I could not find any relevant information in this store’s knowledge base.",
|
||||||
|
sources: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextPieces = chunks.map((chunk, index) => {
|
||||||
|
const label =
|
||||||
|
chunk.sourceType === "FAQ"
|
||||||
|
? `FAQ #${index + 1}`
|
||||||
|
: `Document chunk #${index + 1}`
|
||||||
|
return `${label} (similarity: ${chunk.similarity.toFixed(2)}):\n${
|
||||||
|
chunk.content
|
||||||
|
}`
|
||||||
|
})
|
||||||
|
|
||||||
|
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 =
|
||||||
|
"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" +
|
||||||
|
"If the answer is not clearly present in the context, say that you do not know.\n" +
|
||||||
|
"Be concise and helpful.\n"
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
"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) {
|
||||||
|
const errorText = await response.text().catch(() => "")
|
||||||
|
logger.error("Gemini RAG answer error", {
|
||||||
|
status: response.status,
|
||||||
|
body: errorText,
|
||||||
|
})
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to generate answer from Gemini" },
|
||||||
|
{ 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({
|
||||||
|
answer,
|
||||||
|
sources: chunks.map((chunk) => ({
|
||||||
|
id: chunk.id,
|
||||||
|
type: chunk.sourceType,
|
||||||
|
documentId: chunk.documentId,
|
||||||
|
faqId: chunk.faqItemId,
|
||||||
|
similarity: chunk.similarity,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error querying knowledge base", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to query knowledge base" },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
100
src/app/api/stores/[storeId]/route.ts
Normal file
100
src/app/api/stores/[storeId]/route.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/stores/[storeId]
|
||||||
|
* Delete a store for the authenticated user
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ storeId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storeId } = await params
|
||||||
|
|
||||||
|
if (!storeId) {
|
||||||
|
return NextResponse.json({ message: "Store ID is required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the store belongs to the user
|
||||||
|
const store = await prisma.store.findFirst({
|
||||||
|
where: {
|
||||||
|
id: storeId,
|
||||||
|
userId: session.user.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
return NextResponse.json({ message: "Store not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is the only store
|
||||||
|
const storeCount = await prisma.store.count({
|
||||||
|
where: { userId: session.user.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (storeCount === 1) {
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Cannot delete your only store. Please add another store first."
|
||||||
|
}, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// If deleting the active store, switch to another store first
|
||||||
|
if (store.isActive) {
|
||||||
|
const otherStore = await prisma.store.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
id: { not: storeId }
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (otherStore) {
|
||||||
|
// Switch to another store
|
||||||
|
await prisma.store.updateMany({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
isActive: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.store.update({
|
||||||
|
where: { id: otherStore.id },
|
||||||
|
data: { isActive: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
data: { activeStoreId: otherStore.id }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the store (cascade will handle related data)
|
||||||
|
await prisma.store.delete({
|
||||||
|
where: { id: storeId }
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "Store deleted successfully"
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting store:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to delete store" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
124
src/app/api/stores/[storeId]/webhooks/route.ts
Normal file
124
src/app/api/stores/[storeId]/webhooks/route.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { logger } from "@/lib/logger"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/stores/[storeId]/webhooks
|
||||||
|
* Register webhooks for a store
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ storeId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storeId } = await params
|
||||||
|
|
||||||
|
const store = await prisma.store.findFirst({
|
||||||
|
where: {
|
||||||
|
id: storeId,
|
||||||
|
userId: session.user.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
return NextResponse.json({ message: "Store not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const appUrl = process.env.NEXTAUTH_URL || "https://your-tunnel-url.ngrok-free.app"
|
||||||
|
const webhookTopics = ["orders/create", "orders/updated", "products/create", "products/update", "products/delete"]
|
||||||
|
const cleanDomain = store.shopifyDomain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim()
|
||||||
|
const webhookAddress = `${appUrl}/api/webhooks/shopify`
|
||||||
|
|
||||||
|
const registered: string[] = []
|
||||||
|
const failed: string[] = []
|
||||||
|
|
||||||
|
// First, check existing webhooks to avoid duplicates
|
||||||
|
let existingWebhooks: Array<{ topic: string; id: number; address: string }> = []
|
||||||
|
try {
|
||||||
|
const listResponse = await fetch(`https://${cleanDomain}/admin/api/2024-01/webhooks.json`, {
|
||||||
|
headers: {
|
||||||
|
"X-Shopify-Access-Token": store.shopifyAccessToken,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (listResponse.ok) {
|
||||||
|
const webhooksData = await listResponse.json()
|
||||||
|
existingWebhooks = (webhooksData.webhooks || []).map((wh: { topic: string; id: number; address: string }) => ({
|
||||||
|
topic: wh.topic,
|
||||||
|
id: wh.id,
|
||||||
|
address: wh.address
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Could not fetch existing webhooks for store ${storeId}`, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const topic of webhookTopics) {
|
||||||
|
try {
|
||||||
|
// Check if webhook already exists with same address
|
||||||
|
const existingWebhook = existingWebhooks.find(
|
||||||
|
(wh) => wh.topic === topic && wh.address === webhookAddress
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existingWebhook) {
|
||||||
|
registered.push(topic)
|
||||||
|
logger.info(`Webhook ${topic} already exists for address ${webhookAddress}, skipping registration`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`https://${cleanDomain}/admin/api/2024-01/webhooks.json`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"X-Shopify-Access-Token": store.shopifyAccessToken,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
webhook: {
|
||||||
|
topic,
|
||||||
|
address: webhookAddress,
|
||||||
|
format: "json"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
registered.push(topic)
|
||||||
|
logger.info(`Registered webhook: ${topic} for store ${storeId}`)
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
// Handle "already taken" error gracefully - webhook exists and will work
|
||||||
|
if (errorData.errors?.address?.some((msg: string) => msg.includes("already been taken"))) {
|
||||||
|
registered.push(topic)
|
||||||
|
logger.info(`Webhook ${topic} already registered for address ${webhookAddress}`)
|
||||||
|
} else {
|
||||||
|
logger.error(`Failed to register webhook ${topic}`, errorData)
|
||||||
|
failed.push(topic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Error registering webhook ${topic}`, err)
|
||||||
|
failed.push(topic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
registered,
|
||||||
|
failed,
|
||||||
|
message: `Registered ${registered.length} webhook(s)${failed.length > 0 ? `, ${failed.length} failed` : ""}`
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error registering webhooks:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to register webhooks" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/app/api/stores/route.ts
Normal file
43
src/app/api/stores/route.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/stores
|
||||||
|
* List all stores for the authenticated user
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const stores = await prisma.store.findMany({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
businessName: true,
|
||||||
|
shopifyDomain: true,
|
||||||
|
shopifyStoreName: true,
|
||||||
|
onboardingComplete: true,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: true
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ stores })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching stores:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to fetch stores" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/app/api/stores/switch/route.ts
Normal file
76
src/app/api/stores/switch/route.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/stores/switch
|
||||||
|
* Switch the active store for the user
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storeId } = await request.json()
|
||||||
|
|
||||||
|
if (!storeId) {
|
||||||
|
return NextResponse.json({ message: "Store ID is required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the store belongs to the user
|
||||||
|
const store = await prisma.store.findFirst({
|
||||||
|
where: {
|
||||||
|
id: storeId,
|
||||||
|
userId: session.user.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
return NextResponse.json({ message: "Store not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set all stores to inactive
|
||||||
|
await prisma.store.updateMany({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
isActive: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set the selected store as active
|
||||||
|
await prisma.store.update({
|
||||||
|
where: {
|
||||||
|
id: storeId
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update user's activeStoreId
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: session.user.id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
activeStoreId: storeId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
activeStoreId: storeId
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error switching store:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to switch store" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/app/api/supplier/analytics/route.ts
Normal file
92
src/app/api/supplier/analytics/route.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/supplier/analytics
|
||||||
|
* Get analytics data for supplier
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get supplier profile
|
||||||
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
|
where: { userId: session.user.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supplierProfile) {
|
||||||
|
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get counts
|
||||||
|
const [productsCount, connectedVendorsCount, pendingConnectionsCount] = await Promise.all([
|
||||||
|
prisma.product.count({
|
||||||
|
where: { supplierId: supplierProfile.id }
|
||||||
|
}),
|
||||||
|
prisma.connection.count({
|
||||||
|
where: {
|
||||||
|
supplierId: supplierProfile.id,
|
||||||
|
status: "CONNECTED"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.connection.count({
|
||||||
|
where: {
|
||||||
|
supplierId: supplierProfile.id,
|
||||||
|
status: "PENDING"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
// Calculate average product price
|
||||||
|
const products = await prisma.product.findMany({
|
||||||
|
where: { supplierId: supplierProfile.id },
|
||||||
|
select: { price: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const averagePrice = products.length > 0
|
||||||
|
? products.reduce((sum, p) => sum + p.price, 0) / products.length
|
||||||
|
: 0
|
||||||
|
|
||||||
|
// Get recent activity (last 7 days)
|
||||||
|
const sevenDaysAgo = new Date()
|
||||||
|
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
|
||||||
|
|
||||||
|
const recentProducts = await prisma.product.count({
|
||||||
|
where: {
|
||||||
|
supplierId: supplierProfile.id,
|
||||||
|
createdAt: { gte: sevenDaysAgo }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const recentConnections = await prisma.connection.count({
|
||||||
|
where: {
|
||||||
|
supplierId: supplierProfile.id,
|
||||||
|
createdAt: { gte: sevenDaysAgo }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
stats: {
|
||||||
|
totalProducts: productsCount,
|
||||||
|
connectedVendors: connectedVendorsCount,
|
||||||
|
pendingConnections: pendingConnectionsCount,
|
||||||
|
averageProductPrice: averagePrice,
|
||||||
|
recentProducts,
|
||||||
|
recentConnections
|
||||||
|
},
|
||||||
|
// Placeholder for chart data - would be populated with actual order data
|
||||||
|
chartData: []
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching analytics:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to fetch analytics" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
153
src/app/api/supplier/invitations/accept/route.ts
Normal file
153
src/app/api/supplier/invitations/accept/route.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { notifyInvitationAccepted } from "@/lib/notifications"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/supplier/invitations/accept
|
||||||
|
* Accept an invitation using token
|
||||||
|
*/
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token } = await req.json()
|
||||||
|
|
||||||
|
if (!token || typeof token !== "string") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Invitation token is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get supplier profile
|
||||||
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
|
where: { userId: session.user.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supplierProfile) {
|
||||||
|
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find invitation by token
|
||||||
|
// TypeScript may not recognize invitation model due to cache, but it exists at runtime
|
||||||
|
const invitation = await (prisma as any).invitation.findUnique({
|
||||||
|
where: { token: token.toUpperCase() },
|
||||||
|
include: {
|
||||||
|
store: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
businessName: true,
|
||||||
|
userId: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!invitation) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Invalid invitation token" },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security checks
|
||||||
|
if (invitation.used) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "This invitation has already been used" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new Date() > invitation.expiresAt) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "This invitation has expired" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invitation.supplierId !== supplierProfile.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "This invitation is not for your supplier account" },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if connection already exists
|
||||||
|
const existingConnection = await prisma.connection.findUnique({
|
||||||
|
where: {
|
||||||
|
storeId_supplierId: {
|
||||||
|
storeId: invitation.storeId,
|
||||||
|
supplierId: supplierProfile.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingConnection) {
|
||||||
|
// Mark invitation as used even if connection exists
|
||||||
|
// TypeScript may not recognize invitation model due to cache, but it exists at runtime
|
||||||
|
await (prisma as any).invitation.update({
|
||||||
|
where: { id: invitation.id },
|
||||||
|
data: { used: true, usedAt: new Date() }
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: `Connection already exists with status: ${existingConnection.status}` },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create connection
|
||||||
|
const connection = await prisma.connection.create({
|
||||||
|
data: {
|
||||||
|
storeId: invitation.storeId,
|
||||||
|
supplierId: supplierProfile.id,
|
||||||
|
status: "CONNECTED" // Auto-connect when supplier accepts via token
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mark invitation as used
|
||||||
|
// TypeScript may not recognize invitation model due to cache, but it exists at runtime
|
||||||
|
await (prisma as any).invitation.update({
|
||||||
|
where: { id: invitation.id },
|
||||||
|
data: {
|
||||||
|
used: true,
|
||||||
|
usedAt: new Date()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create notification for vendor
|
||||||
|
try {
|
||||||
|
await notifyInvitationAccepted(
|
||||||
|
invitation.store.userId,
|
||||||
|
supplierProfile.companyName,
|
||||||
|
invitation.store.businessName,
|
||||||
|
connection.id
|
||||||
|
)
|
||||||
|
} catch (notifError) {
|
||||||
|
console.error("Error creating notification:", notifError)
|
||||||
|
// Don't fail the request if notification fails
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Invitation accepted successfully. You are now connected!",
|
||||||
|
connection: {
|
||||||
|
id: connection.id,
|
||||||
|
status: connection.status,
|
||||||
|
vendor: {
|
||||||
|
businessName: invitation.store.businessName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error accepting invitation:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to accept invitation" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
182
src/app/api/supplier/orders/route.ts
Normal file
182
src/app/api/supplier/orders/route.ts
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/supplier/orders
|
||||||
|
* List orders from connected vendors that contain synced products
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get supplier profile
|
||||||
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
|
where: { userId: session.user.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supplierProfile) {
|
||||||
|
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get connected vendors
|
||||||
|
const connections = await prisma.connection.findMany({
|
||||||
|
where: {
|
||||||
|
supplierId: supplierProfile.id,
|
||||||
|
status: "CONNECTED"
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
store: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
businessName: true,
|
||||||
|
shopifyStoreName: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (connections.length === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
orders: [],
|
||||||
|
connectedVendors: 0,
|
||||||
|
syncedProducts: 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all accepted product syncs for this supplier
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const acceptedSyncs = await (prisma as any).productSync.findMany({
|
||||||
|
where: {
|
||||||
|
supplierId: supplierProfile.id,
|
||||||
|
status: "ACCEPTED"
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
shopifyProductId: true,
|
||||||
|
storeId: true,
|
||||||
|
shopifyProductTitle: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (acceptedSyncs.length === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
orders: [],
|
||||||
|
connectedVendors: connections.length,
|
||||||
|
syncedProducts: 0,
|
||||||
|
message: "No products synced yet. Sync products with vendors to see orders."
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group syncs by storeId
|
||||||
|
const syncsByStore = new Map<string, Set<string>>()
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
acceptedSyncs.forEach((sync: any) => {
|
||||||
|
if (!syncsByStore.has(sync.storeId)) {
|
||||||
|
syncsByStore.set(sync.storeId, new Set())
|
||||||
|
}
|
||||||
|
syncsByStore.get(sync.storeId)!.add(sync.shopifyProductId)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get orders from all connected stores that have synced products
|
||||||
|
const storeIds = Array.from(syncsByStore.keys())
|
||||||
|
const cachedOrders = await prisma.cachedOrder.findMany({
|
||||||
|
where: {
|
||||||
|
storeId: { in: storeIds }
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
store: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
businessName: true,
|
||||||
|
shopifyStoreName: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
shopifyCreatedAt: "desc"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filter orders that contain synced products
|
||||||
|
const relevantOrders = cachedOrders.filter((order) => {
|
||||||
|
const syncedProductIds = syncsByStore.get(order.storeId)
|
||||||
|
if (!syncedProductIds || syncedProductIds.size === 0) return false
|
||||||
|
|
||||||
|
// Parse line items from JSON
|
||||||
|
try {
|
||||||
|
const lineItems = typeof order.lineItems === "string"
|
||||||
|
? JSON.parse(order.lineItems)
|
||||||
|
: order.lineItems
|
||||||
|
|
||||||
|
if (!Array.isArray(lineItems)) return false
|
||||||
|
|
||||||
|
// Check if any line item's product_id matches a synced product
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return lineItems.some((item: any) => {
|
||||||
|
const productId = item.product_id?.toString()
|
||||||
|
return productId && syncedProductIds.has(productId)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Transform orders to include supplier-relevant information
|
||||||
|
const transformedOrders = relevantOrders.map((order) => {
|
||||||
|
const syncedProductIds = syncsByStore.get(order.storeId) || new Set()
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let lineItems: any[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
lineItems = typeof order.lineItems === "string"
|
||||||
|
? JSON.parse(order.lineItems)
|
||||||
|
: order.lineItems
|
||||||
|
} catch {
|
||||||
|
lineItems = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter line items to only show synced products
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const syncedLineItems = lineItems.filter((item: any) => {
|
||||||
|
const productId = item.product_id?.toString()
|
||||||
|
return productId && syncedProductIds.has(productId)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: order.shopifyOrderId,
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
email: order.email,
|
||||||
|
totalPrice: order.totalPrice,
|
||||||
|
currency: order.currency,
|
||||||
|
financialStatus: order.financialStatus,
|
||||||
|
fulfillmentStatus: order.fulfillmentStatus,
|
||||||
|
createdAt: order.shopifyCreatedAt || order.createdAt,
|
||||||
|
store: order.store,
|
||||||
|
lineItems: syncedLineItems,
|
||||||
|
// Include all line items for reference, but mark synced ones
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
allLineItems: lineItems.map((item: any) => ({
|
||||||
|
...item,
|
||||||
|
isSynced: syncedProductIds.has(item.product_id?.toString() || "")
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
orders: transformedOrders,
|
||||||
|
connectedVendors: connections.length,
|
||||||
|
syncedProducts: acceptedSyncs.length,
|
||||||
|
totalOrders: transformedOrders.length
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching orders:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to fetch orders" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/app/api/supplier/product-syncs/route.ts
Normal file
71
src/app/api/supplier/product-syncs/route.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/supplier/product-syncs
|
||||||
|
* Get all product sync requests for the authenticated supplier
|
||||||
|
*/
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const status = searchParams.get("status") // "PENDING", "ACCEPTED", "REJECTED", or null for all
|
||||||
|
|
||||||
|
// Get supplier profile
|
||||||
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
|
where: { userId: session.user.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supplierProfile) {
|
||||||
|
return NextResponse.json({ syncs: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build where clause
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const where: any = {
|
||||||
|
supplierId: supplierProfile.id
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status && ["PENDING", "ACCEPTED", "REJECTED"].includes(status)) {
|
||||||
|
where.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get product syncs
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const syncs = await (prisma as any).productSync.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
store: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
businessName: true,
|
||||||
|
shopifyDomain: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ syncs })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching product syncs:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to fetch product syncs" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
184
src/app/api/supplier/products/[productId]/route.ts
Normal file
184
src/app/api/supplier/products/[productId]/route.ts
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/supplier/products/[productId]
|
||||||
|
* Update a product
|
||||||
|
*/
|
||||||
|
export async function PUT(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ productId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { productId } = await params
|
||||||
|
const { title, description, price, sku, imageUrl } = await req.json()
|
||||||
|
|
||||||
|
// Get supplier profile
|
||||||
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
|
where: { userId: session.user.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supplierProfile) {
|
||||||
|
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a synced product (starts with "synced-")
|
||||||
|
if (productId.startsWith("synced-")) {
|
||||||
|
const syncId = productId.replace("synced-", "")
|
||||||
|
|
||||||
|
// Update synced product data in ProductSync
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const productSync = await (prisma as any).productSync.findUnique({
|
||||||
|
where: { id: syncId },
|
||||||
|
include: {
|
||||||
|
supplier: {
|
||||||
|
select: { id: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!productSync || productSync.supplierId !== supplierProfile.id) {
|
||||||
|
return NextResponse.json({ message: "Synced product not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the shopifyProductData with new values
|
||||||
|
const updatedData = {
|
||||||
|
...(productSync.shopifyProductData || {}),
|
||||||
|
title: title || productSync.shopifyProductTitle,
|
||||||
|
body_html: description !== undefined ? description : (productSync.shopifyProductData?.body_html || ""),
|
||||||
|
variants: productSync.shopifyProductData?.variants?.map((v: any, idx: number) => ({
|
||||||
|
...v,
|
||||||
|
price: idx === 0 && price !== undefined ? price.toString() : v.price,
|
||||||
|
sku: idx === 0 && sku !== undefined ? sku : v.sku
|
||||||
|
})) || [],
|
||||||
|
image: imageUrl ? { src: imageUrl } : productSync.shopifyProductData?.image
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await (prisma as any).productSync.update({
|
||||||
|
where: { id: syncId },
|
||||||
|
data: {
|
||||||
|
shopifyProductTitle: title || productSync.shopifyProductTitle,
|
||||||
|
shopifyProductData: updatedData
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Synced product updated successfully",
|
||||||
|
product: {
|
||||||
|
id: productId,
|
||||||
|
syncId,
|
||||||
|
title: title || productSync.shopifyProductTitle,
|
||||||
|
description,
|
||||||
|
price: parseFloat(price || "0"),
|
||||||
|
sku,
|
||||||
|
imageUrl,
|
||||||
|
isSynced: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular product update
|
||||||
|
const existingProduct = await prisma.product.findFirst({
|
||||||
|
where: {
|
||||||
|
id: productId,
|
||||||
|
supplierId: supplierProfile.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existingProduct) {
|
||||||
|
return NextResponse.json({ message: "Product not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = await prisma.product.update({
|
||||||
|
where: { id: productId },
|
||||||
|
data: {
|
||||||
|
title: title || existingProduct.title,
|
||||||
|
description: description !== undefined ? description : existingProduct.description,
|
||||||
|
price: price !== undefined ? parseFloat(price) : existingProduct.price,
|
||||||
|
sku: sku !== undefined ? sku : existingProduct.sku,
|
||||||
|
imageUrl: imageUrl !== undefined ? imageUrl : existingProduct.imageUrl
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Product updated successfully",
|
||||||
|
product
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating product:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to update product" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/supplier/products/[productId]
|
||||||
|
* Delete a product
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ productId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { productId } = await params
|
||||||
|
|
||||||
|
// Get supplier profile
|
||||||
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
|
where: { userId: session.user.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supplierProfile) {
|
||||||
|
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a synced product - synced products cannot be deleted
|
||||||
|
if (productId.startsWith("synced-")) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Synced products from vendors cannot be deleted. You can only edit them." },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify product belongs to supplier
|
||||||
|
const existingProduct = await prisma.product.findFirst({
|
||||||
|
where: {
|
||||||
|
id: productId,
|
||||||
|
supplierId: supplierProfile.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existingProduct) {
|
||||||
|
return NextResponse.json({ message: "Product not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.product.delete({
|
||||||
|
where: { id: productId }
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Product deleted successfully"
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting product:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to delete product" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
145
src/app/api/supplier/products/route.ts
Normal file
145
src/app/api/supplier/products/route.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/supplier/products
|
||||||
|
* List all products for the authenticated supplier
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get supplier profile
|
||||||
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
|
where: { userId: session.user.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supplierProfile) {
|
||||||
|
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get supplier's own products
|
||||||
|
const ownProducts = await prisma.product.findMany({
|
||||||
|
where: {
|
||||||
|
supplierId: supplierProfile.id
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get synced products from vendors (products that vendors synced with this supplier)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const syncedProducts = await (prisma as any).productSync.findMany({
|
||||||
|
where: {
|
||||||
|
supplierId: supplierProfile.id,
|
||||||
|
status: "ACCEPTED"
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
store: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
businessName: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
acceptedAt: "desc"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Transform synced products to match product format
|
||||||
|
const syncedProductsFormatted = syncedProducts.map((sync: any) => {
|
||||||
|
const productData = sync.shopifyProductData || {}
|
||||||
|
return {
|
||||||
|
id: `synced-${sync.id}`, // Unique ID for synced products
|
||||||
|
syncId: sync.id,
|
||||||
|
shopifyProductId: sync.shopifyProductId,
|
||||||
|
title: sync.shopifyProductTitle,
|
||||||
|
description: productData.body_html || productData.description || null,
|
||||||
|
price: parseFloat(productData.variants?.[0]?.price || "0"),
|
||||||
|
sku: productData.variants?.[0]?.sku || null,
|
||||||
|
imageUrl: productData.image?.src || productData.images?.[0]?.src || null,
|
||||||
|
createdAt: sync.createdAt,
|
||||||
|
updatedAt: sync.updatedAt || sync.acceptedAt,
|
||||||
|
vendor: {
|
||||||
|
id: sync.store.id,
|
||||||
|
businessName: sync.store.businessName
|
||||||
|
},
|
||||||
|
isSynced: true, // Flag to indicate this is a synced product from vendor
|
||||||
|
// Include full Shopify product data for reference
|
||||||
|
shopifyData: productData
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Combine own products and synced products
|
||||||
|
const allProducts = [...ownProducts, ...syncedProductsFormatted]
|
||||||
|
|
||||||
|
return NextResponse.json({ products: allProducts })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching products:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to fetch products" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/supplier/products
|
||||||
|
* Create a new product
|
||||||
|
*/
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description, price, sku, imageUrl } = await req.json()
|
||||||
|
|
||||||
|
if (!title || price === undefined) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Title and price are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get supplier profile
|
||||||
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
|
where: { userId: session.user.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supplierProfile) {
|
||||||
|
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = await prisma.product.create({
|
||||||
|
data: {
|
||||||
|
supplierId: supplierProfile.id,
|
||||||
|
title,
|
||||||
|
description: description || null,
|
||||||
|
price: parseFloat(price),
|
||||||
|
sku: sku || null,
|
||||||
|
imageUrl: imageUrl || null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Product created successfully",
|
||||||
|
product
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating product:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to create product" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src/app/api/supplier/profile/route.ts
Normal file
100
src/app/api/supplier/profile/route.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/supplier/profile
|
||||||
|
* Get supplier profile for authenticated user
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
products: true,
|
||||||
|
connections: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supplierProfile) {
|
||||||
|
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ profile: supplierProfile })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching supplier profile:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to fetch supplier profile" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/supplier/profile
|
||||||
|
* Create or update supplier profile
|
||||||
|
*/
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { companyName, description } = await req.json()
|
||||||
|
|
||||||
|
if (!companyName) {
|
||||||
|
return NextResponse.json({ message: "Company name is required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert supplier profile
|
||||||
|
const supplierProfile = await prisma.supplierProfile.upsert({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
update: {
|
||||||
|
companyName,
|
||||||
|
description: description || null
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: session.user.id,
|
||||||
|
companyName,
|
||||||
|
description: description || null
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Supplier profile updated successfully",
|
||||||
|
profile: supplierProfile
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating supplier profile:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to update supplier profile" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/app/api/supplier/vendors/[connectionId]/route.ts
vendored
Normal file
121
src/app/api/supplier/vendors/[connectionId]/route.ts
vendored
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/supplier/vendors/[connectionId]
|
||||||
|
* Update connection status (accept/reject)
|
||||||
|
*/
|
||||||
|
export async function PUT(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ connectionId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { connectionId } = await params
|
||||||
|
const { status } = await req.json()
|
||||||
|
|
||||||
|
if (!status || !["CONNECTED", "REJECTED"].includes(status)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Valid status (CONNECTED or REJECTED) is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get supplier profile
|
||||||
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
|
where: { userId: session.user.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supplierProfile) {
|
||||||
|
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify connection belongs to supplier
|
||||||
|
const connection = await prisma.connection.findFirst({
|
||||||
|
where: {
|
||||||
|
id: connectionId,
|
||||||
|
supplierId: supplierProfile.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
return NextResponse.json({ message: "Connection not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedConnection = await prisma.connection.update({
|
||||||
|
where: { id: connectionId },
|
||||||
|
data: { status: status as "CONNECTED" | "REJECTED" }
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: `Connection ${status.toLowerCase()} successfully`,
|
||||||
|
connection: updatedConnection
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating connection:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to update connection" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/supplier/vendors/[connectionId]
|
||||||
|
* Delete a connection
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ connectionId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { connectionId } = await params
|
||||||
|
|
||||||
|
// Get supplier profile
|
||||||
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
|
where: { userId: session.user.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supplierProfile) {
|
||||||
|
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify connection belongs to supplier
|
||||||
|
const connection = await prisma.connection.findFirst({
|
||||||
|
where: {
|
||||||
|
id: connectionId,
|
||||||
|
supplierId: supplierProfile.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
return NextResponse.json({ message: "Connection not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.connection.delete({
|
||||||
|
where: { id: connectionId }
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Connection deleted successfully"
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting connection:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to delete connection" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/app/api/supplier/vendors/route.ts
vendored
Normal file
59
src/app/api/supplier/vendors/route.ts
vendored
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/supplier/vendors
|
||||||
|
* List all vendor connections for the authenticated supplier
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get supplier profile
|
||||||
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
|
where: { userId: session.user.id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supplierProfile) {
|
||||||
|
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const connections = await prisma.connection.findMany({
|
||||||
|
where: {
|
||||||
|
supplierId: supplierProfile.id
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
store: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
businessName: true,
|
||||||
|
shopifyDomain: true,
|
||||||
|
shopifyStoreName: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ connections })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching vendor connections:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to fetch vendor connections" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
317
src/app/api/vendors/suppliers/[supplierId]/invite/route.ts
vendored
Normal file
317
src/app/api/vendors/suppliers/[supplierId]/invite/route.ts
vendored
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { sendEmail, generateInvitationEmailHtml } from "@/lib/email"
|
||||||
|
import { notifyInvitationReceived } from "@/lib/notifications"
|
||||||
|
import crypto from "crypto"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/vendors/suppliers/[supplierId]/invite
|
||||||
|
* Cancel a pending invitation
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ supplierId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "VENDOR") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { supplierId } = await params
|
||||||
|
const body = await req.json().catch(() => ({}))
|
||||||
|
const storeId = body.storeId // Optional: specific store to cancel, otherwise cancel all
|
||||||
|
|
||||||
|
// Get vendor's stores
|
||||||
|
const vendorStores = await prisma.store.findMany({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
onboardingComplete: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (vendorStores.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "No stores found" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeIdsToCheck = storeId
|
||||||
|
? [storeId]
|
||||||
|
: vendorStores.map(s => s.id)
|
||||||
|
|
||||||
|
// Find and delete pending invitations
|
||||||
|
const pendingInvitations = await (prisma as any).invitation.findMany({
|
||||||
|
where: {
|
||||||
|
storeId: { in: storeIdsToCheck },
|
||||||
|
supplierId: supplierId,
|
||||||
|
used: false,
|
||||||
|
expiresAt: {
|
||||||
|
gt: new Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (pendingInvitations.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "No pending invitations found" },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all pending invitations
|
||||||
|
await (prisma as any).invitation.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: { in: pendingInvitations.map((inv: any) => inv.id) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: `Successfully cancelled ${pendingInvitations.length} invitation(s)`
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error cancelling invitation:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to cancel invitation" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/vendors/suppliers/[supplierId]/invite
|
||||||
|
* Send invitation to a supplier
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ supplierId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "VENDOR") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { supplierId } = await params
|
||||||
|
const body = await req.json().catch(() => ({}))
|
||||||
|
const connectToAllStores = body.connectToAllStores === true
|
||||||
|
|
||||||
|
// Get vendor's stores
|
||||||
|
const vendorStores = await prisma.store.findMany({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
onboardingComplete: true // Only include completed stores
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
businessName: true,
|
||||||
|
userId: true,
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (vendorStores.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "No stores found. Please complete onboarding first." },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which stores to connect
|
||||||
|
const storesToConnect = connectToAllStores
|
||||||
|
? vendorStores
|
||||||
|
: vendorStores.filter(store => store.isActive)
|
||||||
|
|
||||||
|
if (storesToConnect.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "No active store found. Please activate a store or complete onboarding." },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get supplier profile with user info
|
||||||
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
|
where: { id: supplierId },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get vendor stores with user info for notifications
|
||||||
|
const vendorStoresWithUser = await prisma.store.findMany({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
onboardingComplete: true
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
businessName: true,
|
||||||
|
userId: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supplierProfile) {
|
||||||
|
return NextResponse.json({ message: "Supplier not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email exists
|
||||||
|
if (!supplierProfile.user.email) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Supplier email not found" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing connections and invitations for all stores
|
||||||
|
const storeIds = storesToConnect.map(s => s.id)
|
||||||
|
const existingConnections = await prisma.connection.findMany({
|
||||||
|
where: {
|
||||||
|
storeId: { in: storeIds },
|
||||||
|
supplierId: supplierProfile.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const existingInvitations = await (prisma as any).invitation.findMany({
|
||||||
|
where: {
|
||||||
|
storeId: { in: storeIds },
|
||||||
|
supplierId: supplierProfile.id,
|
||||||
|
used: false,
|
||||||
|
expiresAt: {
|
||||||
|
gt: new Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filter out stores that already have connections or pending invitations
|
||||||
|
const connectedStoreIds = new Set([
|
||||||
|
...existingConnections.map(c => c.storeId),
|
||||||
|
...existingInvitations.map((i: any) => i.storeId)
|
||||||
|
])
|
||||||
|
|
||||||
|
const storesToInvite = storesToConnect.filter(store => !connectedStoreIds.has(store.id))
|
||||||
|
|
||||||
|
if (storesToInvite.length === 0) {
|
||||||
|
const allConnected = storesToConnect.length === existingConnections.length + existingInvitations.length
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
message: allConnected
|
||||||
|
? "All selected stores are already connected or have pending invitations"
|
||||||
|
: "No stores available to invite (all have existing connections or pending invitations)"
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate invitation tokens and create invitations for each store
|
||||||
|
const expiresAt = new Date()
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + 7) // 7 days expiry
|
||||||
|
|
||||||
|
const invitations = []
|
||||||
|
const storeNames = storesToInvite.map(s => s.businessName).join(", ")
|
||||||
|
|
||||||
|
for (const store of storesToInvite) {
|
||||||
|
const invitationToken = crypto.randomBytes(32).toString("hex").toUpperCase().slice(0, 16)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const invitation = await (prisma as any).invitation.create({
|
||||||
|
data: {
|
||||||
|
token: invitationToken,
|
||||||
|
storeId: store.id,
|
||||||
|
supplierId: supplierProfile.id,
|
||||||
|
email: supplierProfile.user.email,
|
||||||
|
expiresAt
|
||||||
|
}
|
||||||
|
})
|
||||||
|
invitations.push({ id: invitation.id, storeId: store.id, token: invitationToken })
|
||||||
|
} catch (dbError: any) {
|
||||||
|
console.error(`Database error creating invitation for store ${store.id}:`, dbError)
|
||||||
|
// Continue with other stores
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invitations.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to create invitations" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send invitation email (use first store's name or combined names)
|
||||||
|
const primaryStore = storesToInvite[0]
|
||||||
|
const invitationLink = `${process.env.NEXTAUTH_URL || "http://localhost:3000"}/supplier/vendors?token=${invitations[0].token}`
|
||||||
|
|
||||||
|
const emailSubject = connectToAllStores && storesToInvite.length > 1
|
||||||
|
? `Invitation to Connect - ${storesToInvite.length} Stores`
|
||||||
|
: `Invitation to Connect - ${primaryStore.businessName}`
|
||||||
|
|
||||||
|
const emailResult = await sendEmail({
|
||||||
|
to: supplierProfile.user.email,
|
||||||
|
subject: emailSubject,
|
||||||
|
html: generateInvitationEmailHtml(
|
||||||
|
supplierProfile.user.name || supplierProfile.companyName,
|
||||||
|
connectToAllStores && storesToInvite.length > 1 ? storeNames : primaryStore.businessName,
|
||||||
|
invitations[0].token, // Use first token in email
|
||||||
|
invitationLink
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!emailResult.success) {
|
||||||
|
console.warn("Email sending failed (but invitations created):", emailResult.error)
|
||||||
|
// Continue anyway - invitations are created, supplier can use tokens manually
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create notification for supplier about the invitation
|
||||||
|
try {
|
||||||
|
const vendorName = session.user.name || storesToInvite[0].businessName
|
||||||
|
const storeDisplayName = connectToAllStores && storesToInvite.length > 1
|
||||||
|
? `${storesToInvite.length} Stores`
|
||||||
|
: primaryStore.businessName
|
||||||
|
|
||||||
|
await notifyInvitationReceived(
|
||||||
|
supplierProfile.user.id,
|
||||||
|
vendorName,
|
||||||
|
storeDisplayName,
|
||||||
|
invitations[0].id // Use first invitation ID
|
||||||
|
)
|
||||||
|
} catch (notifError) {
|
||||||
|
console.error("Error creating notification:", notifError)
|
||||||
|
// Don't fail the request if notification fails
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: connectToAllStores && storesToInvite.length > 1
|
||||||
|
? `Invitations sent successfully to ${invitations.length} store(s)`
|
||||||
|
: "Invitation sent successfully",
|
||||||
|
invitations: invitations.map(inv => ({
|
||||||
|
id: inv.id,
|
||||||
|
storeId: inv.storeId,
|
||||||
|
token: inv.token,
|
||||||
|
expiresAt
|
||||||
|
})),
|
||||||
|
storesConnected: invitations.length,
|
||||||
|
totalStores: storesToConnect.length
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error sending invitation:", error)
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||||
|
const errorStack = error instanceof Error ? error.stack : undefined
|
||||||
|
console.error("Error details:", { errorMessage, errorStack })
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
message: "Failed to send invitation",
|
||||||
|
error: errorMessage
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
133
src/app/api/vendors/suppliers/my/route.ts
vendored
Normal file
133
src/app/api/vendors/suppliers/my/route.ts
vendored
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/vendors/suppliers/my
|
||||||
|
* List connected suppliers for the authenticated vendor
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "VENDOR") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get vendor's stores
|
||||||
|
const vendorStores = await prisma.store.findMany({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
onboardingComplete: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (vendorStores.length === 0) {
|
||||||
|
return NextResponse.json({ connections: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all connections for this vendor's stores
|
||||||
|
const vendorStoreIds = vendorStores.map(s => s.id)
|
||||||
|
const connections = await prisma.connection.findMany({
|
||||||
|
where: {
|
||||||
|
storeId: { in: vendorStoreIds }
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
store: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
businessName: true,
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
supplier: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
companyName: true,
|
||||||
|
description: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
products: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get pending invitations (not yet accepted - no connection created)
|
||||||
|
const pendingInvitations = await (prisma as any).invitation.findMany({
|
||||||
|
where: {
|
||||||
|
storeId: { in: vendorStoreIds },
|
||||||
|
used: false,
|
||||||
|
expiresAt: {
|
||||||
|
gt: new Date()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
supplier: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
companyName: true,
|
||||||
|
description: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
products: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Convert invitations to connection-like objects with PENDING status
|
||||||
|
const pendingConnections = pendingInvitations.map((invitation: any) => ({
|
||||||
|
id: invitation.id, // Use invitation ID as temporary connection ID
|
||||||
|
status: "PENDING" as const,
|
||||||
|
createdAt: invitation.createdAt,
|
||||||
|
supplier: invitation.supplier,
|
||||||
|
store: {
|
||||||
|
id: invitation.storeId,
|
||||||
|
businessName: vendorStores.find(s => s.id === invitation.storeId)?.businessName || "Unknown Store",
|
||||||
|
isActive: vendorStores.find(s => s.id === invitation.storeId)?.isActive || false
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Combine connections and pending invitations, removing duplicates
|
||||||
|
// (if a connection exists, don't include the invitation)
|
||||||
|
const connectionSupplierIds = new Set(connections.map(c => c.supplier.id))
|
||||||
|
const uniquePendingConnections = pendingConnections.filter(
|
||||||
|
(pc: any) => !connectionSupplierIds.has(pc.supplier.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
const allConnections = [...connections, ...uniquePendingConnections].sort(
|
||||||
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ connections: allConnections })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching my suppliers:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to fetch suppliers" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/app/api/vendors/suppliers/route.ts
vendored
Normal file
106
src/app/api/vendors/suppliers/route.ts
vendored
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/vendors/suppliers
|
||||||
|
* List all suppliers globally (for vendors to discover)
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "VENDOR") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all supplier profiles with their user info and product counts
|
||||||
|
const suppliers = await prisma.supplierProfile.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
companyName: true,
|
||||||
|
description: true,
|
||||||
|
createdAt: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
products: true,
|
||||||
|
connections: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get vendor's store to check existing connections
|
||||||
|
const vendorStore = await prisma.store.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mark which suppliers are already connected/pending
|
||||||
|
const suppliersWithStatus = await Promise.all(
|
||||||
|
suppliers.map(async (supplier) => {
|
||||||
|
let connectionStatus: "NONE" | "PENDING" | "CONNECTED" | "REJECTED" = "NONE"
|
||||||
|
|
||||||
|
if (vendorStore) {
|
||||||
|
// First check for existing connection
|
||||||
|
const connection = await prisma.connection.findUnique({
|
||||||
|
where: {
|
||||||
|
storeId_supplierId: {
|
||||||
|
storeId: vendorStore.id,
|
||||||
|
supplierId: supplier.id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
status: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (connection) {
|
||||||
|
connectionStatus = connection.status as "PENDING" | "CONNECTED" | "REJECTED"
|
||||||
|
} else {
|
||||||
|
// Check for pending invitation (not yet accepted)
|
||||||
|
const pendingInvitation = await (prisma as any).invitation.findFirst({
|
||||||
|
where: {
|
||||||
|
storeId: vendorStore.id,
|
||||||
|
supplierId: supplier.id,
|
||||||
|
used: false,
|
||||||
|
expiresAt: {
|
||||||
|
gt: new Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (pendingInvitation) {
|
||||||
|
connectionStatus = "PENDING"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...supplier,
|
||||||
|
connectionStatus
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ suppliers: suppliersWithStatus })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching suppliers:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to fetch suppliers" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/app/api/vendors/synced-products/route.ts
vendored
Normal file
81
src/app/api/vendors/synced-products/route.ts
vendored
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/vendors/synced-products
|
||||||
|
* Get all synced products for the vendor (read-only)
|
||||||
|
* These are products that vendors have synced with suppliers
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session?.user?.id || session.user.role !== "VENDOR") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get vendor's active store
|
||||||
|
const vendorStore = await prisma.store.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!vendorStore) {
|
||||||
|
return NextResponse.json({ products: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all ACCEPTED product syncs for this store
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const acceptedSyncs = await (prisma as any).productSync.findMany({
|
||||||
|
where: {
|
||||||
|
storeId: vendorStore.id,
|
||||||
|
status: "ACCEPTED"
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
supplier: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
companyName: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
acceptedAt: "desc"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Transform syncs into product-like objects for display
|
||||||
|
const syncedProducts = acceptedSyncs.map((sync: any) => {
|
||||||
|
const productData = sync.shopifyProductData || {}
|
||||||
|
return {
|
||||||
|
id: sync.id,
|
||||||
|
syncId: sync.id,
|
||||||
|
shopifyProductId: sync.shopifyProductId,
|
||||||
|
title: sync.shopifyProductTitle,
|
||||||
|
description: productData.body_html || productData.description || null,
|
||||||
|
price: productData.variants?.[0]?.price || "0",
|
||||||
|
sku: productData.variants?.[0]?.sku || null,
|
||||||
|
imageUrl: productData.image?.src || productData.images?.[0]?.src || null,
|
||||||
|
supplier: {
|
||||||
|
id: sync.supplier.id,
|
||||||
|
companyName: sync.supplier.companyName
|
||||||
|
},
|
||||||
|
syncedAt: sync.acceptedAt || sync.createdAt,
|
||||||
|
isSynced: true, // Flag to indicate this is a synced product
|
||||||
|
// Include full Shopify product data for reference
|
||||||
|
shopifyData: productData
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ products: syncedProducts })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching synced products:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Failed to fetch synced products" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
157
src/app/api/webhooks/shopify/route.ts
Normal file
157
src/app/api/webhooks/shopify/route.ts
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { updateSingleProduct, updateSingleOrder } from "@/lib/cache"
|
||||||
|
import crypto from "crypto"
|
||||||
|
import { logger } from "@/lib/logger"
|
||||||
|
|
||||||
|
// Ensure raw body is available for HMAC verification
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify Shopify webhook HMAC signature
|
||||||
|
*/
|
||||||
|
function verifyWebhook(hmac: string, body: string, secret: string): boolean {
|
||||||
|
const hash = crypto
|
||||||
|
.createHmac("sha256", secret)
|
||||||
|
.update(body, "utf8")
|
||||||
|
.digest("base64")
|
||||||
|
return hash === hmac
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const hmac = req.headers.get("x-shopify-hmac-sha256")
|
||||||
|
const topic = req.headers.get("x-shopify-topic")
|
||||||
|
const domain = req.headers.get("x-shopify-shop-domain")
|
||||||
|
|
||||||
|
if (!hmac || !topic || !domain) {
|
||||||
|
logger.error("[Webhook] Missing required headers")
|
||||||
|
return new NextResponse("Missing Shopify headers", { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get raw body for HMAC verification
|
||||||
|
const rawBody = await req.text()
|
||||||
|
const body = JSON.parse(rawBody)
|
||||||
|
|
||||||
|
logger.info(`[Shopify Webhook] Received ${topic} from ${domain}`)
|
||||||
|
|
||||||
|
// Clean domain to match database format
|
||||||
|
const cleanDomain = domain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim()
|
||||||
|
|
||||||
|
// Find the store by domain
|
||||||
|
let store = await prisma.store.findFirst({
|
||||||
|
where: { shopifyDomain: cleanDomain }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
store = await prisma.store.findFirst({
|
||||||
|
where: { shopifyDomain: domain }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
logger.error(`[Webhook] Store not found for domain: ${domain}`)
|
||||||
|
return new NextResponse("Store not found", { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify HMAC signature
|
||||||
|
if (!verifyWebhook(hmac, rawBody, store.shopifyApiSecret)) {
|
||||||
|
logger.error(`[Webhook] HMAC verification failed for ${domain}`)
|
||||||
|
return new NextResponse("Invalid signature", { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle specific topics and update cache + sync metadata
|
||||||
|
switch (topic) {
|
||||||
|
case "orders/create":
|
||||||
|
case "orders/updated":
|
||||||
|
logger.info(`[Webhook] Processing order ${topic}: ${body.id}`)
|
||||||
|
await updateSingleOrder(store.id, body)
|
||||||
|
// Update sync metadata to invalidate cache
|
||||||
|
await prisma.syncMetadata.upsert({
|
||||||
|
where: {
|
||||||
|
storeId_resourceType: {
|
||||||
|
storeId: store.id,
|
||||||
|
resourceType: "orders"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
storeId: store.id,
|
||||||
|
resourceType: "orders",
|
||||||
|
lastSyncAt: new Date(),
|
||||||
|
syncStatus: "success",
|
||||||
|
recordCount: 1
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
lastSyncAt: new Date(),
|
||||||
|
syncStatus: "success"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
case "products/update":
|
||||||
|
case "products/create":
|
||||||
|
logger.info(`[Webhook] Processing product ${topic}: ${body.id}`)
|
||||||
|
await updateSingleProduct(store.id, body)
|
||||||
|
// Update sync metadata to invalidate cache
|
||||||
|
await prisma.syncMetadata.upsert({
|
||||||
|
where: {
|
||||||
|
storeId_resourceType: {
|
||||||
|
storeId: store.id,
|
||||||
|
resourceType: "products"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
storeId: store.id,
|
||||||
|
resourceType: "products",
|
||||||
|
lastSyncAt: new Date(),
|
||||||
|
syncStatus: "success",
|
||||||
|
recordCount: 1
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
lastSyncAt: new Date(),
|
||||||
|
syncStatus: "success"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
case "products/delete":
|
||||||
|
logger.info(`[Webhook] Processing product deletion: ${body.id}`)
|
||||||
|
await prisma.cachedProduct.deleteMany({
|
||||||
|
where: {
|
||||||
|
shopifyProductId: body.id.toString(),
|
||||||
|
storeId: store.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// Update sync metadata
|
||||||
|
await prisma.syncMetadata.upsert({
|
||||||
|
where: {
|
||||||
|
storeId_resourceType: {
|
||||||
|
storeId: store.id,
|
||||||
|
resourceType: "products"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
storeId: store.id,
|
||||||
|
resourceType: "products",
|
||||||
|
lastSyncAt: new Date(),
|
||||||
|
syncStatus: "success"
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
lastSyncAt: new Date()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
logger.warn(`[Webhook] Unhandled topic: ${topic}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[Webhook] Successfully processed ${topic} for ${domain}`)
|
||||||
|
return new NextResponse("OK", { status: 200 })
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("[Webhook] Processing error:", error)
|
||||||
|
return new NextResponse("Internal Server Error", { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
180
src/app/dashboard/customers/page.tsx
Normal file
180
src/app/dashboard/customers/page.tsx
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Search,
|
||||||
|
MoreHorizontal,
|
||||||
|
Mail,
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
MapPin
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
interface ShopifyCustomer {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
first_name: string
|
||||||
|
last_name: string
|
||||||
|
orders_count: number
|
||||||
|
state: string
|
||||||
|
total_spent: string
|
||||||
|
last_order_id: number | null
|
||||||
|
created_at: string
|
||||||
|
default_address: {
|
||||||
|
city: string
|
||||||
|
country_name: string
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomersPage() {
|
||||||
|
const [customers, setCustomers] = useState<ShopifyCustomer[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
const [searchTerm, setSearchTerm] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCustomers = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/shopify/customers")
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch customers")
|
||||||
|
const data = await res.json()
|
||||||
|
setCustomers(data.customers || [])
|
||||||
|
setError("")
|
||||||
|
} catch {
|
||||||
|
setError("Could not load customers. Please ensure your Shopify store is connected.")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial fetch
|
||||||
|
fetchCustomers()
|
||||||
|
|
||||||
|
// Auto-refresh every 20 seconds to catch webhook updates
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
fetchCustomers()
|
||||||
|
}, 20000) // 20 seconds
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filteredCustomers = customers.filter(c =>
|
||||||
|
`${c.first_name} ${c.last_name}`.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
c.email.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col space-y-4 md:space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 flex-shrink-0">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">Customers</h1>
|
||||||
|
<p className="text-sm md:text-base text-slate-400 mt-1">View and manage your Shopify customer base.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button className="h-10 md:h-11 px-4 md:px-6 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">
|
||||||
|
<Plus className="w-4 h-4" /> <span className="hidden sm:inline">Add Customer</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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="p-4 md:p-6 border-b border-white/10 flex flex-col sm:flex-row gap-3 md:gap-4 justify-between bg-white/5 flex-shrink-0">
|
||||||
|
<div className="relative flex-1 min-w-0">
|
||||||
|
<Search className="absolute left-3 md:left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by name or email..."
|
||||||
|
value={searchTerm}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto overflow-x-auto -mx-4 md:mx-0">
|
||||||
|
<div className="min-w-full px-4 md:px-0">
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-64 flex flex-col items-center justify-center text-slate-400 gap-3">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
|
||||||
|
<p className="font-medium animate-pulse">Loading customers...</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="h-64 flex flex-col items-center justify-center text-red-400 gap-4 p-8 text-center">
|
||||||
|
<div className="w-12 h-12 bg-red-500/10 border border-red-500/20 rounded-2xl flex items-center justify-center">
|
||||||
|
<AlertCircle className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<p className="font-bold">{error}</p>
|
||||||
|
</div>
|
||||||
|
) : filteredCustomers.length === 0 ? (
|
||||||
|
<div className="h-64 flex flex-col items-center justify-center text-slate-400 gap-3">
|
||||||
|
<Users className="w-10 h-10 opacity-20" />
|
||||||
|
<p className="font-medium">No customers found.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-white/5 border-b border-white/10">
|
||||||
|
<th className="px-4 md:px-8 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest min-w-[200px]">Customer</th>
|
||||||
|
<th className="px-3 md:px-6 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap">Orders</th>
|
||||||
|
<th className="px-3 md:px-6 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap">Spent</th>
|
||||||
|
<th className="px-3 md:px-6 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap hidden md:table-cell">Location</th>
|
||||||
|
<th className="px-3 md:px-6 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap hidden lg:table-cell">Member Since</th>
|
||||||
|
<th className="px-4 md:px-8 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">
|
||||||
|
{filteredCustomers.map((customer) => (
|
||||||
|
<tr key={customer.id} className="hover:bg-white/5 transition-colors group">
|
||||||
|
<td className="px-4 md:px-8 py-3 md:py-4">
|
||||||
|
<div className="flex items-center gap-2 md:gap-3">
|
||||||
|
<div className="w-9 h-9 md:w-10 md:h-10 rounded-full bg-white/10 flex items-center justify-center border border-white/10 font-bold text-slate-400 shrink-0">
|
||||||
|
{customer.first_name?.[0]}{customer.last_name?.[0]}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
|
<span className="font-bold text-sm md:text-base text-white truncate">{customer.first_name} {customer.last_name}</span>
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] text-slate-400 font-medium">
|
||||||
|
<Mail className="w-3 h-3 shrink-0" />
|
||||||
|
<span className="truncate">{customer.email}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-400 font-medium md:hidden mt-1">
|
||||||
|
<MapPin className="w-3 h-3 text-slate-500 shrink-0" />
|
||||||
|
<span className="truncate">{customer.default_address ? `${customer.default_address.city}, ${customer.default_address.country_name}` : "Not provided"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 md:px-6 py-3 md:py-4 text-center">
|
||||||
|
<div className="px-2 py-1 bg-indigo-500/20 text-indigo-400 rounded-lg inline-flex items-center gap-1.5 font-black text-[10px]">
|
||||||
|
{customer.orders_count} ORDERS
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 md:px-6 py-3 md:py-4">
|
||||||
|
<span className="text-xs md:text-sm font-black text-white">${customer.total_spent}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 md:px-6 py-3 md:py-4 hidden md:table-cell">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-400 font-medium">
|
||||||
|
<MapPin className="w-3 h-3 text-slate-500 shrink-0" />
|
||||||
|
<span className="truncate">{customer.default_address ? `${customer.default_address.city}, ${customer.default_address.country_name}` : "Not provided"}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 md:px-6 py-3 md:py-4 hidden lg:table-cell">
|
||||||
|
<span className="text-xs text-slate-500 font-medium">{new Date(customer.created_at).toLocaleDateString()}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 md:px-8 py-3 md:py-4 text-right">
|
||||||
|
<button className="p-2 hover:bg-white/10 rounded-lg text-slate-400 hover:text-indigo-400 transition-all">
|
||||||
|
<MoreHorizontal className="w-4 h-4 md:w-5 md:h-5" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user