From ac27cc554b5fe3bb1cb182cfed7693325d5d6527 Mon Sep 17 00:00:00 2001 From: Andymick Date: Sat, 18 Jul 2026 17:11:10 +0100 Subject: [PATCH] Add design approval workflow infrastructure: PATCH endpoint, email notifications, and message types - Add PATCH method to /api/designs/[id]/route.ts for status updates - Create sendDesignReadyForReview email template for customer notifications - Enhance /api/designs/[id]/messages to support messageType field (MESSAGE | CHANGE_REQUEST) - Add database migration for messageType field with MESSAGE default This completes the backend infrastructure for the multi-step design approval workflow: 1. Admin edits and sends design to customer for review 2. Customer approves design or requests changes 3. Admin receives notifications for change requests Co-Authored-By: Claude Haiku 4.5 --- .claude/settings.local.json | 5 +- .../migration.sql | 2 + prisma/schema.prisma | 3 +- src/app/admin/designs/[id]/edit/page.tsx | 100 +++++++++ src/app/admin/designs/[id]/page.tsx | 41 +--- src/app/api/designs/[id]/messages/route.ts | 18 +- src/app/api/designs/[id]/route.ts | 30 ++- .../designs/[id]/send-to-customer/route.ts | 48 +++++ src/app/designs/[id]/review/page.tsx | 191 ++++++++++++++++++ src/lib/mail.ts | 35 ++++ 10 files changed, 431 insertions(+), 42 deletions(-) create mode 100644 prisma/migrations/20260718155552_add_message_type_to_design_approval_message/migration.sql create mode 100644 src/app/admin/designs/[id]/edit/page.tsx create mode 100644 src/app/api/designs/[id]/send-to-customer/route.ts create mode 100644 src/app/designs/[id]/review/page.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ede0d84..674e951 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -52,7 +52,10 @@ "Bash(git commit -m 'Reorganize design canvas layout to be more compact *)", "Bash(git commit -m 'Make image dimensions and font properties collapsible sections *)", "Bash(rm -rf \"D:\\\\Craft2Prints\\\\src\\\\app\\\\api\\\\designs\\\\[id]\\\\delete\" && echo \"Deleted unnecessary delete folder\")", - "Bash(git commit -m 'Add customer approval status messages and admin delete option *)" + "Bash(git commit -m 'Add customer approval status messages and admin delete option *)", + "Bash(grep -n '`' \"D:\\\\Craft2Prints\\\\src\\\\components\\\\DesignCanvas.tsx\")", + "Bash(npx prettier *)", + "Bash(git commit -m 'Add design approval workflow infrastructure: PATCH endpoint, email notifications, and message types *)" ] } } diff --git a/prisma/migrations/20260718155552_add_message_type_to_design_approval_message/migration.sql b/prisma/migrations/20260718155552_add_message_type_to_design_approval_message/migration.sql new file mode 100644 index 0000000..2babe51 --- /dev/null +++ b/prisma/migrations/20260718155552_add_message_type_to_design_approval_message/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "DesignApprovalMessage" ADD COLUMN "messageType" TEXT NOT NULL DEFAULT 'MESSAGE'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5835d0b..18f48bd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -196,7 +196,7 @@ model DesignApproval { designImageDimensions String? // JSON array of {imageId, widthCm, heightCm} for each uploaded image color String size String? - status String @default("PENDING") // PENDING | APPROVED | REJECTED + status String @default("PENDING") // PENDING | IN_REVIEW | APPROVED | REJECTED messages DesignApprovalMessage[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -209,6 +209,7 @@ model DesignApprovalMessage { design DesignApproval @relation(fields: [designId], references: [id], onDelete: Cascade) sender String // "ADMIN" | "CUSTOMER" body String + messageType String @default("MESSAGE") // MESSAGE | CHANGE_REQUEST createdAt DateTime @default(now()) } diff --git a/src/app/admin/designs/[id]/edit/page.tsx b/src/app/admin/designs/[id]/edit/page.tsx new file mode 100644 index 0000000..065650e --- /dev/null +++ b/src/app/admin/designs/[id]/edit/page.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import dynamic from 'next/dynamic'; + +interface DesignApproval { + id: string; + customerEmail: string; + customerName: string | null; + productId: string; + product: { + id: string; + name: string; + basePrice: number; + personalisedPrice: number; + onSale: boolean; + effectivePrice: number; + colors: string[]; + sizes: string[]; + showOnPersonalised: boolean; + imageUrl: string | null; + imageUrlBack: string | null; + photoRecolorable: boolean; + colorPhotos: Record; + colorPhotosBack: Record; + printArea: string; + printAreaBack: string | null; + mockup: string; + referenceWidthCm: number | null; + referenceHeightCm: number | null; + }; + designJson: string; + color: string; + size: string | null; + status: string; +} + +const DesignCanvas = dynamic(() => import('@/components/DesignCanvas'), { ssr: false }); + +export default function EditDesignPage({ params }: { params: { id: string } }) { + const router = useRouter(); + const [design, setDesign] = useState(null); + const [loading, setLoading] = useState(true); + const [sending, setSending] = useState(false); + + useEffect(() => { + fetch(`/api/designs/${params.id}`) + .then((r) => r.json()) + .then(setDesign) + .finally(() => setLoading(false)); + }, [params.id]); + + const handleSendToCustomer = async () => { + setSending(true); + try { + const res = await fetch(`/api/designs/${params.id}/send-to-customer`, { + method: 'POST', + }); + if (res.ok) { + router.push(`/admin/designs/${params.id}`); + } else { + alert('Failed to send to customer'); + } + } finally { + setSending(false); + } + }; + + if (loading) return
Loading...
; + if (!design) return
Design not found.
; + + return ( +
+
+ + ← Back to design approval + + +
+
+

{design.product.name}

+

Editing design for {design.customerName || design.customerEmail}

+
+ +
+ + {/* Full Design Canvas */} + +
+
+ ); +} diff --git a/src/app/admin/designs/[id]/page.tsx b/src/app/admin/designs/[id]/page.tsx index 1f072a1..5ee7a19 100644 --- a/src/app/admin/designs/[id]/page.tsx +++ b/src/app/admin/designs/[id]/page.tsx @@ -4,7 +4,6 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import DesignApprovalChat from '@/components/DesignApprovalChat'; -import AdminDesignEditor from '@/components/AdminDesignEditor'; interface DesignApproval { id: string; @@ -34,7 +33,6 @@ export default function DesignDetailPage({ params }: { params: { id: string } }) const [loading, setLoading] = useState(true); const [approving, setApproving] = useState(false); const [deleting, setDeleting] = useState(false); - const [editingDesign, setEditingDesign] = useState(false); useEffect(() => { fetch(`/api/designs/${params.id}`) @@ -75,19 +73,6 @@ export default function DesignDetailPage({ params }: { params: { id: string } }) } }; - const handleEditorSave = (designJson: string, previewUrl: string) => { - setDesign((prev) => - prev - ? { - ...prev, - designJson, - designPreviewUrl: previewUrl, - } - : null - ); - setEditingDesign(false); - }; - return (
@@ -100,27 +85,18 @@ export default function DesignDetailPage({ params }: { params: { id: string } }) {/* Design Preview & Editor */}
-

Design Editor

- {!editingDesign && design.status === 'PENDING' && ( - + Open full editor → + )}
- {editingDesign ? ( - - ) : ( -
+

Design preview (transparent)

)}
- )}
{/* Customer Details & Chat */} diff --git a/src/app/api/designs/[id]/messages/route.ts b/src/app/api/designs/[id]/messages/route.ts index 3a1a6a4..9334425 100644 --- a/src/app/api/designs/[id]/messages/route.ts +++ b/src/app/api/designs/[id]/messages/route.ts @@ -27,24 +27,30 @@ export async function POST( { params }: { params: { id: string } } ) { try { - const { sender, body } = await req.json(); + const { sender, body, role, message, messageType } = await req.json(); - if (!sender || !body) { + // Support both old format (sender/body) and new format (role/message/messageType) + const finalSender = role || sender; + const finalBody = message || body; + const finalMessageType = messageType || 'MESSAGE'; + + if (!finalSender || !finalBody) { return NextResponse.json( { error: 'Missing required fields' }, { status: 400 } ); } - const message = await prisma.designApprovalMessage.create({ + const created = await prisma.designApprovalMessage.create({ data: { designId: params.id, - sender, - body, + sender: finalSender, + body: finalBody, + messageType: finalMessageType, }, }); - return NextResponse.json(message); + return NextResponse.json(created); } catch (err) { console.error('Error creating message:', err); return NextResponse.json( diff --git a/src/app/api/designs/[id]/route.ts b/src/app/api/designs/[id]/route.ts index c16b49f..2b6804d 100644 --- a/src/app/api/designs/[id]/route.ts +++ b/src/app/api/designs/[id]/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; -import { getCurrentAdmin } from '@/lib/auth'; +import { getCurrentAdmin, getCurrentCustomer } from '@/lib/auth'; export async function GET( req: Request, @@ -26,6 +26,34 @@ export async function GET( } } +export async function PATCH( + req: Request, + { params }: { params: { id: string } } +) { + try { + const customer = await getCurrentCustomer(); + if (!customer) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { status } = await req.json(); + + const updated = await prisma.designApproval.update({ + where: { id: params.id }, + data: { status }, + include: { product: true, messages: { orderBy: { createdAt: 'asc' } } }, + }); + + return NextResponse.json(updated); + } catch (err) { + console.error('Error updating design:', err); + return NextResponse.json( + { error: 'Failed to update design' }, + { status: 500 } + ); + } +} + export async function DELETE( req: Request, { params }: { params: { id: string } } diff --git a/src/app/api/designs/[id]/send-to-customer/route.ts b/src/app/api/designs/[id]/send-to-customer/route.ts new file mode 100644 index 0000000..0e69abe --- /dev/null +++ b/src/app/api/designs/[id]/send-to-customer/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { getCurrentAdmin } from '@/lib/auth'; +import { sendDesignReadyForReview } from '@/lib/mail'; + +export async function POST( + req: Request, + { params }: { params: { id: string } } +) { + try { + const admin = await getCurrentAdmin(); + if (!admin) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const design = await prisma.designApproval.findUnique({ + where: { id: params.id }, + include: { product: true }, + }); + + if (!design) { + return NextResponse.json({ error: 'Design not found' }, { status: 404 }); + } + + // Update status to IN_REVIEW + const updated = await prisma.designApproval.update({ + where: { id: params.id }, + data: { status: 'IN_REVIEW' }, + include: { product: true, messages: { orderBy: { createdAt: 'asc' } } }, + }); + + // Send email to customer + await sendDesignReadyForReview({ + customerName: design.customerName || 'Customer', + customerEmail: design.customerEmail, + productName: design.product.name, + reviewLink: `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}/designs/${params.id}/review`, + }); + + return NextResponse.json(updated); + } catch (err) { + console.error('Error sending design to customer:', err); + return NextResponse.json( + { error: 'Failed to send design' }, + { status: 500 } + ); + } +} diff --git a/src/app/designs/[id]/review/page.tsx b/src/app/designs/[id]/review/page.tsx new file mode 100644 index 0000000..7218c70 --- /dev/null +++ b/src/app/designs/[id]/review/page.tsx @@ -0,0 +1,191 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { useCart } from '@/lib/cartStore'; + +interface DesignApproval { + id: string; + customerEmail: string; + customerName: string | null; + productId: string; + product: { + id: string; + name: string; + basePrice: number; + personalisedPrice: number; + effectivePrice: number; + }; + designJson: string; + designPreviewUrl: string; + placementPreviewUrl: string | null; + color: string; + size: string | null; + status: string; +} + +export default function DesignReviewPage({ params }: { params: { id: string } }) { + const [design, setDesign] = useState(null); + const [loading, setLoading] = useState(true); + const [approving, setApproving] = useState(false); + const [changingMessage, setChangingMessage] = useState(''); + const [sendingChanges, setSendingChanges] = useState(false); + const addItem = useCart((s) => s.addItem); + + useEffect(() => { + fetch(`/api/designs/${params.id}`) + .then((r) => r.json()) + .then(setDesign) + .finally(() => setLoading(false)); + }, [params.id]); + + const handleApprove = async () => { + if (!design) return; + setApproving(true); + try { + const res = await fetch(`/api/designs/${params.id}/approve`, { method: 'POST' }); + if (res.ok) { + const updated = await res.json(); + setDesign(updated); + + // Add to cart + addItem({ + productId: design.productId, + color: design.color, + size: design.size, + designJson: design.designJson, + quantity: 1, + }); + + alert('Design approved! Added to your bag.'); + } + } finally { + setApproving(false); + } + }; + + const handleRequestChanges = async () => { + if (!changingMessage.trim()) { + alert('Please provide feedback for the design changes'); + return; + } + + setSendingChanges(true); + try { + const res = await fetch(`/api/designs/${params.id}/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: changingMessage, + role: 'CUSTOMER', + messageType: 'CHANGE_REQUEST', + }), + }); + + if (res.ok) { + // Update status back to PENDING so admin can see it + await fetch(`/api/designs/${params.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'PENDING' }), + }); + + alert('Changes requested! The admin has been notified.'); + setChangingMessage(''); + // Reload design + const updated = await fetch(`/api/designs/${params.id}`).then((r) => r.json()); + setDesign(updated); + } + } finally { + setSendingChanges(false); + } + }; + + if (loading) return
Loading...
; + if (!design) return
Design not found.
; + + return ( +
+ + ← Back to shop + + +

{design.product.name}

+

Review your personalized design

+ +
+ {/* Design Preview */} +
+
+

Design preview

+ design preview +
+ + {design.placementPreviewUrl && ( +
+

Design on product

+ placement preview +
+ )} +
+ + {/* Actions */} +
+
+

Order Summary

+
+

+ Product: {design.product.name} +

+

+ Color: {design.color} +

+ {design.size && ( +

+ Size: {design.size} +

+ )} +

+ Price: £{(design.product.effectivePrice / 100).toFixed(2)} +

+
+ + +
+ + {/* Request Changes */} +
+

Request Changes

+