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 +

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 +

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

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

JUNOHUB