diff --git a/package.json b/package.json
index 76f3ada..90b729c 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/scripts/fix-failed-migration.ts b/scripts/fix-failed-migration.ts
new file mode 100644
index 0000000..b01a1d8
--- /dev/null
+++ b/scripts/fix-failed-migration.ts
@@ -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)
+})
diff --git a/src/app/api/auth/password-reset/request/route.ts b/src/app/api/auth/password-reset/request/route.ts
index 16db5cb..d7736cb 100644
--- a/src/app/api/auth/password-reset/request/route.ts
+++ b/src/app/api/auth/password-reset/request/route.ts
@@ -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) {
+
+
+
+
+ })
+ JUNOHUB
+
+
+ |
+
Reset Your Password
diff --git a/src/app/api/auth/verify-email/send/route.ts b/src/app/api/auth/verify-email/send/route.ts
index 8fb221d..ccf690f 100644
--- a/src/app/api/auth/verify-email/send/route.ts
+++ b/src/app/api/auth/verify-email/send/route.ts
@@ -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) {
|
+
+
+
+
+ })
+ JUNOHUB
+
+
+ |
+
Verify Your Email
diff --git a/src/app/api/newsletter/subscribe/route.ts b/src/app/api/newsletter/subscribe/route.ts
index dbc57db..ab26480 100644
--- a/src/app/api/newsletter/subscribe/route.ts
+++ b/src/app/api/newsletter/subscribe/route.ts
@@ -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 {
|
-
+
JUNOHUB
diff --git a/src/app/api/shopify/validate/route.ts b/src/app/api/shopify/validate/route.ts
index 58b747a..0667b6d 100644
--- a/src/app/api/shopify/validate/route.ts
+++ b/src/app/api/shopify/validate/route.ts
@@ -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 {
+ 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)
diff --git a/src/lib/email.ts b/src/lib/email.ts
index 4e49c60..892488c 100644
--- a/src/lib/email.ts
+++ b/src/lib/email.ts
@@ -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(
-
+
JUNOHUB
| | | |