email logo fixed and timeout retry for validation of shopify credentials

This commit is contained in:
root 2026-02-26 11:33:45 +05:00 committed by talatops
parent d1a1dfc4f5
commit fadec2e06f
7 changed files with 159 additions and 44 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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