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",
|
||||
"start": "next start",
|
||||
"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": {
|
||||
"@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 { prisma } from "@/lib/prisma"
|
||||
import { sendEmail } from "@/lib/email"
|
||||
import { sendEmail, getEmailLogoUrl } from "@/lib/email"
|
||||
import { emailSchema } from "@/lib/validation"
|
||||
import { applyRateLimit } from "@/lib/rate-limit"
|
||||
import { handleApiError } from "@/lib/errors"
|
||||
@ -80,6 +80,16 @@ export async function POST(req: Request) {
|
||||
<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 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>
|
||||
<td style="padding: 40px;">
|
||||
<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 { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { sendEmail } from "@/lib/email"
|
||||
import { sendEmail, getEmailLogoUrl } from "@/lib/email"
|
||||
import { applyRateLimit } from "@/lib/rate-limit"
|
||||
import { handleApiError } from "@/lib/errors"
|
||||
import { getBaseUrl } from "@/lib/utils"
|
||||
@ -79,6 +79,16 @@ export async function POST(req: Request) {
|
||||
<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 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>
|
||||
<td style="padding: 40px;">
|
||||
<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 { sendEmail } from "@/lib/email"
|
||||
import { sendEmail, getEmailLogoUrl } from "@/lib/email"
|
||||
import { logger } from "@/lib/logger"
|
||||
|
||||
// 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%);">
|
||||
<div style="text-align: center;">
|
||||
<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;">
|
||||
<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>
|
||||
<img src="${getEmailLogoUrl()}" alt="JUNOHUB" width="56" height="56" style="display: block; border-radius: 16px; object-fit: contain;" />
|
||||
<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>
|
||||
|
||||
@ -1,5 +1,19 @@
|
||||
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) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
@ -12,45 +26,56 @@ export async function POST(req: Request) {
|
||||
// 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)
|
||||
let lastError: unknown = null
|
||||
|
||||
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
|
||||
})
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000)
|
||||
|
||||
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) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
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: "Invalid credentials. Please verify your domain and access token.",
|
||||
details: errorData
|
||||
}, { status: response.status })
|
||||
}
|
||||
message: "Validation successful!",
|
||||
shop: data.shop
|
||||
})
|
||||
} catch (fetchError: unknown) {
|
||||
clearTimeout(timeoutId)
|
||||
lastError = fetchError
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json({
|
||||
message: "Validation successful!",
|
||||
shop: data.shop
|
||||
})
|
||||
if (fetchError instanceof Error && fetchError.name === "AbortError") {
|
||||
return NextResponse.json({ message: "Connection to Shopify timed out. Please check the domain." }, { status: 408 })
|
||||
}
|
||||
|
||||
} 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 })
|
||||
if (isTransientError(fetchError) && attempt < MAX_RETRIES) {
|
||||
console.warn(`Shopify validation attempt ${attempt}/${MAX_RETRIES} failed (transient), retrying...`, fetchError)
|
||||
await sleep(RETRY_DELAY_MS)
|
||||
continue
|
||||
}
|
||||
|
||||
throw fetchError
|
||||
}
|
||||
throw fetchError
|
||||
}
|
||||
|
||||
throw lastError
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error("Shopify validation error:", error)
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
import nodemailer from "nodemailer"
|
||||
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)
|
||||
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);">
|
||||
<div style="text-align: center;">
|
||||
<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;">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user