Set up JUNOHUB app and EC2 deployment

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root 2026-02-09 20:12:52 +05:00
parent f666c25eea
commit 284fb12576
182 changed files with 30722 additions and 465 deletions

115
.github/workflows/deploy-ec2.yml vendored Normal file
View 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
View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

53
package.json Normal file
View 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
View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

14
prisma.config.ts Normal file
View 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"],
},
});

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View File

@ -1 +0,0 @@
TASK_TO_RUN=edit_distance

View File

@ -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

View File

@ -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

View 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

View File

@ -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;
}

View File

@ -1,5 +0,0 @@
master slots=1
worker1 slots=1
worker2 slots=1
worker3 slots=1
worker4 slots=1

View File

@ -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
View 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)
})

View 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>
)
}

View 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>
)
}

View 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
View 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>
)
}

View 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
View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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 },
)
}
}

View 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 },
)
}
}

View 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 },
)
}
}

View 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 },
)
}
}

View 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 },
)
}
}

View 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 },
)
}
}

View File

@ -0,0 +1,2 @@
import { handlers } from "@/auth"
export const { GET, POST } = handlers

View 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 }
)
}
}

View 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")
}
}

View 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")
}
}

View 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 })
}
}

View 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")
}
}

View 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")
}
}

View 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 }
)
}
}

View 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")
)
}
}

View 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 }
)
}
}

View 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")
}
}

View 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 }
)
}
}

View 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")
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 })
}
}

View 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 })
}
}

View 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 })
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 })
}
}

View 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 })
}
}

View 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")
}
}

View 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 })
}
}

View 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 })
}
}

View 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 },
)
}
}

View 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 },
)
}
}

View 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 },
)
}
}

View 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 },
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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
View 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 }
)
}
}

View 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 }
)
}
}

View 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
View 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 }
)
}
}

View 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 }
)
}
}

View 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 })
}
}

View 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