email logo fixed and timeout retry for validation of shopify credentials
This commit is contained in:
parent
d1a1dfc4f5
commit
fadec2e06f
@ -7,7 +7,10 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"re-embed-knowledge-base": "tsx scripts/re-embed-knowledge-base.ts"
|
"re-embed-knowledge-base": "tsx scripts/re-embed-knowledge-base.ts",
|
||||||
|
"db:reset": "prisma migrate reset",
|
||||||
|
"db:studio": "prisma studio",
|
||||||
|
"db:fix-migration": "tsx scripts/fix-failed-migration.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/core": "^0.41.0",
|
"@auth/core": "^0.41.0",
|
||||||
|
|||||||
69
scripts/fix-failed-migration.ts
Normal file
69
scripts/fix-failed-migration.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
/**
|
||||||
|
* Fix Prisma failed migration (P3009) when database has partial schema.
|
||||||
|
*
|
||||||
|
* Use when: migrate deploy fails with "UserRole already exists" or similar,
|
||||||
|
* and Prisma reports a failed migration that blocks new migrations.
|
||||||
|
*
|
||||||
|
* This script:
|
||||||
|
* 1. Marks the failed migration as rolled back
|
||||||
|
* 2. Drops and recreates the public schema (clears all objects)
|
||||||
|
* 3. Re-applies migrations
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/fix-failed-migration.ts
|
||||||
|
*
|
||||||
|
* Requires: DATABASE_URL in .env (e.g. postgresql://postgres:password@localhost:5444/dropshipping)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import "dotenv/config"
|
||||||
|
import { execSync } from "child_process"
|
||||||
|
import { Client } from "pg"
|
||||||
|
|
||||||
|
const MIGRATION_NAME = "20260216152711_init_schema"
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const dbUrl = process.env.DATABASE_URL
|
||||||
|
if (!dbUrl) {
|
||||||
|
console.error("DATABASE_URL is not set in .env")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Step 1: Marking failed migration as rolled back...")
|
||||||
|
try {
|
||||||
|
execSync(`npx prisma migrate resolve --rolled-back ${MIGRATION_NAME}`, {
|
||||||
|
stdio: "inherit",
|
||||||
|
env: { ...process.env, DATABASE_URL: dbUrl },
|
||||||
|
})
|
||||||
|
console.log(" ✓ Migration marked as rolled back")
|
||||||
|
} catch (e) {
|
||||||
|
// May fail if migration record doesn't exist - continue anyway
|
||||||
|
console.log(" (Migration resolve completed or migration not in failed state)")
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\nStep 2: Dropping and recreating public schema...")
|
||||||
|
const client = new Client({ connectionString: dbUrl })
|
||||||
|
await client.connect()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("DROP SCHEMA public CASCADE")
|
||||||
|
await client.query("CREATE SCHEMA public")
|
||||||
|
await client.query("GRANT ALL ON SCHEMA public TO postgres")
|
||||||
|
await client.query("GRANT ALL ON SCHEMA public TO public")
|
||||||
|
await client.query("CREATE EXTENSION IF NOT EXISTS vector")
|
||||||
|
console.log(" ✓ Schema reset complete")
|
||||||
|
} finally {
|
||||||
|
await client.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\nStep 3: Applying migrations...")
|
||||||
|
execSync("npx prisma migrate deploy", {
|
||||||
|
stdio: "inherit",
|
||||||
|
env: { ...process.env, DATABASE_URL: dbUrl },
|
||||||
|
})
|
||||||
|
console.log("\n✓ Migration fix complete. Database is ready.")
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("Fix failed:", err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { sendEmail } from "@/lib/email"
|
import { sendEmail, getEmailLogoUrl } from "@/lib/email"
|
||||||
import { emailSchema } from "@/lib/validation"
|
import { emailSchema } from "@/lib/validation"
|
||||||
import { applyRateLimit } from "@/lib/rate-limit"
|
import { applyRateLimit } from "@/lib/rate-limit"
|
||||||
import { handleApiError } from "@/lib/errors"
|
import { handleApiError } from "@/lib/errors"
|
||||||
@ -80,6 +80,16 @@ export async function POST(req: Request) {
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 20px; text-align: center;">
|
<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;">
|
<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 40px 30px; background: linear-gradient(135deg, rgba(79, 70, 229, 0.2) 0%, rgba(99, 102, 241, 0.1) 100%); border-bottom: 1px solid rgba(255, 255, 255, 0.1);">
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<div style="display: inline-flex; align-items: center; justify-content: center; gap: 12px; margin-bottom: 16px;">
|
||||||
|
<img src="${getEmailLogoUrl()}" alt="JUNOHUB" width="48" height="48" style="display: block; border-radius: 12px; object-fit: contain;" />
|
||||||
|
<h1 style="margin: 0; font-size: 28px; font-weight: 800; color: #ffffff; letter-spacing: -0.5px;">JUNOHUB</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px;">
|
<td style="padding: 40px;">
|
||||||
<h2 style="margin: 0 0 16px; font-size: 24px; font-weight: 700; color: #ffffff;">Reset Your Password</h2>
|
<h2 style="margin: 0 0 16px; font-size: 24px; font-weight: 700; color: #ffffff;">Reset Your Password</h2>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { sendEmail } from "@/lib/email"
|
import { sendEmail, getEmailLogoUrl } from "@/lib/email"
|
||||||
import { applyRateLimit } from "@/lib/rate-limit"
|
import { applyRateLimit } from "@/lib/rate-limit"
|
||||||
import { handleApiError } from "@/lib/errors"
|
import { handleApiError } from "@/lib/errors"
|
||||||
import { getBaseUrl } from "@/lib/utils"
|
import { getBaseUrl } from "@/lib/utils"
|
||||||
@ -79,6 +79,16 @@ export async function POST(req: Request) {
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 20px; text-align: center;">
|
<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;">
|
<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 40px 30px; background: linear-gradient(135deg, rgba(79, 70, 229, 0.2) 0%, rgba(99, 102, 241, 0.1) 100%); border-bottom: 1px solid rgba(255, 255, 255, 0.1);">
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<div style="display: inline-flex; align-items: center; justify-content: center; gap: 12px; margin-bottom: 16px;">
|
||||||
|
<img src="${getEmailLogoUrl()}" alt="JUNOHUB" width="48" height="48" style="display: block; border-radius: 12px; object-fit: contain;" />
|
||||||
|
<h1 style="margin: 0; font-size: 28px; font-weight: 800; color: #ffffff; letter-spacing: -0.5px;">JUNOHUB</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px;">
|
<td style="padding: 40px;">
|
||||||
<h2 style="margin: 0 0 16px; font-size: 24px; font-weight: 700; color: #ffffff;">Verify Your Email</h2>
|
<h2 style="margin: 0 0 16px; font-size: 24px; font-weight: 700; color: #ffffff;">Verify Your Email</h2>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { sendEmail } from "@/lib/email"
|
import { sendEmail, getEmailLogoUrl } from "@/lib/email"
|
||||||
import { logger } from "@/lib/logger"
|
import { logger } from "@/lib/logger"
|
||||||
|
|
||||||
// This is a simple mock-up of a newsletter subscription API
|
// This is a simple mock-up of a newsletter subscription API
|
||||||
@ -63,11 +63,7 @@ function generateNewsletterWelcomeHtml(email: string): string {
|
|||||||
<td style="padding: 50px 40px 30px; background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%);">
|
<td style="padding: 50px 40px 30px; background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%);">
|
||||||
<div style="text-align: center;">
|
<div style="text-align: center;">
|
||||||
<div style="display: inline-flex; align-items: center; justify-content: center; gap: 12px; margin-bottom: 20px;">
|
<div style="display: inline-flex; align-items: center; justify-content: center; gap: 12px; margin-bottom: 20px;">
|
||||||
<div style="width: 56px; height: 56px; background-color: rgba(255, 255, 255, 0.2); border-radius: 16px; display: inline-block; vertical-align: middle;">
|
<img src="${getEmailLogoUrl()}" alt="JUNOHUB" width="56" height="56" style="display: block; border-radius: 16px; object-fit: contain;" />
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-top: 12px;">
|
|
||||||
<path d="M13 2L3 14H12L11 22L21 10H12L13 2Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h1 style="margin: 0; font-size: 32px; font-weight: 900; color: #ffffff; letter-spacing: -1px; display: inline-block; vertical-align: middle; margin-left: 10px;">JUNOHUB</h1>
|
<h1 style="margin: 0; font-size: 32px; font-weight: 900; color: #ffffff; letter-spacing: -1px; display: inline-block; vertical-align: middle; margin-left: 10px;">JUNOHUB</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,19 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
|
|
||||||
|
const TRANSIENT_ERROR_CODES = ["ETIMEDOUT", "ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN"]
|
||||||
|
const MAX_RETRIES = 3
|
||||||
|
const RETRY_DELAY_MS = 1500
|
||||||
|
|
||||||
|
function isTransientError(error: unknown): boolean {
|
||||||
|
const err = error as { code?: string; cause?: { code?: string; errors?: Array<{ code?: string }> }; errors?: Array<{ code?: string }> }
|
||||||
|
const code = err?.code ?? err?.cause?.code ?? err?.cause?.errors?.[0]?.code ?? err?.errors?.[0]?.code
|
||||||
|
return code ? TRANSIENT_ERROR_CODES.includes(code) : false
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const body = await req.json()
|
const body = await req.json()
|
||||||
@ -12,45 +26,56 @@ export async function POST(req: Request) {
|
|||||||
// Clean domain: remove protocol, trailing slashes, and trim whitespace
|
// Clean domain: remove protocol, trailing slashes, and trim whitespace
|
||||||
const cleanDomain = domain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim()
|
const cleanDomain = domain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim()
|
||||||
|
|
||||||
// Create an AbortController for timeout
|
let lastError: unknown = null
|
||||||
const controller = new AbortController()
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 10000)
|
|
||||||
|
|
||||||
try {
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||||
// Shopify Shop API endpoint
|
const controller = new AbortController()
|
||||||
const response = await fetch(`https://${cleanDomain}/admin/api/2024-01/shop.json`, {
|
const timeoutId = setTimeout(() => controller.abort(), 10000)
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"X-Shopify-Access-Token": accessToken,
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
signal: controller.signal
|
|
||||||
})
|
|
||||||
|
|
||||||
clearTimeout(timeoutId)
|
try {
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
clearTimeout(timeoutId)
|
||||||
const errorData = await response.json().catch(() => ({}))
|
|
||||||
|
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({
|
return NextResponse.json({
|
||||||
message: "Invalid credentials. Please verify your domain and access token.",
|
message: "Validation successful!",
|
||||||
details: errorData
|
shop: data.shop
|
||||||
}, { status: response.status })
|
})
|
||||||
}
|
} catch (fetchError: unknown) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
lastError = fetchError
|
||||||
|
|
||||||
const data = await response.json()
|
if (fetchError instanceof Error && fetchError.name === "AbortError") {
|
||||||
return NextResponse.json({
|
return NextResponse.json({ message: "Connection to Shopify timed out. Please check the domain." }, { status: 408 })
|
||||||
message: "Validation successful!",
|
}
|
||||||
shop: data.shop
|
|
||||||
})
|
|
||||||
|
|
||||||
} catch (fetchError: unknown) {
|
if (isTransientError(fetchError) && attempt < MAX_RETRIES) {
|
||||||
clearTimeout(timeoutId)
|
console.warn(`Shopify validation attempt ${attempt}/${MAX_RETRIES} failed (transient), retrying...`, fetchError)
|
||||||
if (fetchError instanceof Error && fetchError.name === "AbortError") {
|
await sleep(RETRY_DELAY_MS)
|
||||||
return NextResponse.json({ message: "Connection to Shopify timed out. Please check the domain." }, { status: 408 })
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
throw fetchError
|
||||||
}
|
}
|
||||||
throw fetchError
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw lastError
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : "Unknown error"
|
const message = error instanceof Error ? error.message : "Unknown error"
|
||||||
console.error("Shopify validation error:", error)
|
console.error("Shopify validation error:", error)
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
import nodemailer from "nodemailer"
|
import nodemailer from "nodemailer"
|
||||||
import { logger } from "@/lib/logger"
|
import { logger } from "@/lib/logger"
|
||||||
|
import { getBaseUrl } from "@/lib/utils"
|
||||||
|
|
||||||
|
/** Returns the absolute URL for the JUNOHUB logo (for use in email templates) */
|
||||||
|
export function getEmailLogoUrl(): string {
|
||||||
|
return `${getBaseUrl()}/icon.png`
|
||||||
|
}
|
||||||
|
|
||||||
// Create reusable transporter (only if SMTP is configured)
|
// Create reusable transporter (only if SMTP is configured)
|
||||||
function getTransporter() {
|
function getTransporter() {
|
||||||
@ -80,11 +86,7 @@ export function generateInvitationEmailHtml(
|
|||||||
<td style="padding: 40px 40px 30px; background: linear-gradient(135deg, rgba(79, 70, 229, 0.2) 0%, rgba(99, 102, 241, 0.1) 100%); border-bottom: 1px solid rgba(255, 255, 255, 0.1);">
|
<td style="padding: 40px 40px 30px; background: linear-gradient(135deg, rgba(79, 70, 229, 0.2) 0%, rgba(99, 102, 241, 0.1) 100%); border-bottom: 1px solid rgba(255, 255, 255, 0.1);">
|
||||||
<div style="text-align: center;">
|
<div style="text-align: center;">
|
||||||
<div style="display: inline-flex; align-items: center; justify-content: center; gap: 12px; margin-bottom: 16px;">
|
<div style="display: inline-flex; align-items: center; justify-content: center; gap: 12px; margin-bottom: 16px;">
|
||||||
<div style="width: 48px; height: 48px; background-color: rgba(79, 70, 229, 0.4); border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.2); display: flex; align-items: center; justify-content: center;">
|
<img src="${getEmailLogoUrl()}" alt="JUNOHUB" width="48" height="48" style="display: block; border-radius: 12px; object-fit: contain;" />
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="display: block;">
|
|
||||||
<path d="M13 2L3 14H12L11 22L21 10H12L13 2Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h1 style="margin: 0; font-size: 28px; font-weight: 800; color: #ffffff; letter-spacing: -0.5px;">JUNOHUB</h1>
|
<h1 style="margin: 0; font-size: 28px; font-weight: 800; color: #ffffff; letter-spacing: -0.5px;">JUNOHUB</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user