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