feat: add reviews api routes using shopify metaobjects and app proxy authentication
This commit is contained in:
parent
88b44085e6
commit
c91cffab72
@ -1,45 +1,178 @@
|
||||
import prisma from "../db.server";
|
||||
import { authenticate, unauthenticated } from "../shopify.server";
|
||||
|
||||
const jsonResponse = (data, status = 200) => {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
async function ensureDefinition(admin) {
|
||||
console.log(">>> [DEBUG] Triggering Metaobject Definition Creation...");
|
||||
const mutation = `#graphql
|
||||
mutation CreateMetaobjectDefinition($definition: MetaobjectDefinitionCreateInput!) {
|
||||
metaobjectDefinitionCreate(definition: $definition) {
|
||||
metaobjectDefinition { type }
|
||||
userErrors { field message }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const variables = {
|
||||
definition: {
|
||||
name: "Product Review",
|
||||
type: "product_review",
|
||||
access: { admin: "MERCHANT_READ_WRITE", storefront: "PUBLIC_READ" },
|
||||
capabilities: { publishable: { enabled: true } },
|
||||
fieldDefinitions: [
|
||||
{ name: "Product ID", key: "product_id", type: "single_line_text_field", required: true },
|
||||
{ name: "Customer Name", key: "customer_name", type: "single_line_text_field" },
|
||||
{ name: "Review Content", key: "content", type: "multi_line_text_field" },
|
||||
{ name: "Rating", key: "rating", type: "single_line_text_field" }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const response = await admin.graphql(mutation, { variables });
|
||||
const result = await response.json();
|
||||
console.log(">>> [DEBUG] Creation Result:", JSON.stringify(result));
|
||||
return result;
|
||||
}
|
||||
|
||||
// GET → fetch reviews
|
||||
export const loader = async ({ request }) => {
|
||||
try {
|
||||
const { admin, session } = await authenticate.public.appProxy(request);
|
||||
const url = new URL(request.url);
|
||||
const productId = url.searchParams.get("productId");
|
||||
|
||||
if (!productId) {
|
||||
return new Response("Product ID missing", { status: 400 });
|
||||
if (!productId) return jsonResponse([]);
|
||||
|
||||
const query = `#graphql
|
||||
query getReviews($query: String!) {
|
||||
metaobjects(first: 50, type: "product_review", query: $query) {
|
||||
edges { node { fields { key value } } }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await admin.graphql(query, {
|
||||
variables: { query: `product_id:${productId}` },
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Check for "No definition" error
|
||||
const isMissing = data.errors?.some(e => e.message.toLowerCase().includes("metaobject definition")) || !data.data?.metaobjects;
|
||||
if (isMissing) {
|
||||
console.log(">>> [DEBUG] Loader: Definition missing. Attempting to create...");
|
||||
await ensureDefinition(admin);
|
||||
return jsonResponse([]);
|
||||
}
|
||||
|
||||
const reviews = await prisma.review.findMany({
|
||||
where: { productId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
const reviews = (data.data?.metaobjects?.edges || []).map((edge) => {
|
||||
const fields = {};
|
||||
edge.node.fields.forEach((f) => { fields[f.key] = f.value; });
|
||||
return {
|
||||
name: fields.customer_name || "Anonymous",
|
||||
review: fields.content || "",
|
||||
rating: fields.rating || "5",
|
||||
};
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(reviews), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
return jsonResponse(reviews);
|
||||
} catch (err) {
|
||||
console.error(">>> [ERROR] Loader:", err.message);
|
||||
return jsonResponse([]);
|
||||
}
|
||||
};
|
||||
|
||||
// POST → save review
|
||||
export const action = async ({ request }) => {
|
||||
const formData = await request.formData();
|
||||
try {
|
||||
console.log(">>> [DEBUG] Action: Review Submission Started");
|
||||
const { admin, session } = await authenticate.public.appProxy(request);
|
||||
const url = new URL(request.url);
|
||||
const customerId = url.searchParams.get("logged_in_customer_id");
|
||||
|
||||
if (!customerId) return jsonResponse({ error: "Please log in to leave a review." });
|
||||
|
||||
const formData = await request.formData();
|
||||
const productId = formData.get("productId");
|
||||
const name = formData.get("name");
|
||||
const review = formData.get("review");
|
||||
const productId = formData.get("productId");
|
||||
|
||||
if (!productId) {
|
||||
return new Response("Product ID missing", { status: 400 });
|
||||
// 1. Purchase Check
|
||||
const orderQuery = `#graphql
|
||||
query getOrders($query: String!) {
|
||||
orders(first: 10, query: $query) {
|
||||
edges { node { lineItems(first: 20) { edges { node { product { id } } } } } }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const orderResponse = await admin.graphql(orderQuery, {
|
||||
variables: { query: `customer_id:${customerId}` }
|
||||
});
|
||||
|
||||
const orderData = await orderResponse.json();
|
||||
const hasPurchased = (orderData.data?.orders?.edges || []).some(o =>
|
||||
o.node.lineItems.edges.some(i => i.node.product?.id?.includes(productId))
|
||||
);
|
||||
|
||||
if (!hasPurchased) {
|
||||
console.log(">>> [DEBUG] Action: No purchase history found for Customer ID:", customerId);
|
||||
return jsonResponse({ error: "Only verified buyers can leave a review. (Note: It may take a few minutes for new orders to sync)" });
|
||||
}
|
||||
|
||||
await prisma.review.create({
|
||||
data: {
|
||||
productId,
|
||||
name,
|
||||
review,
|
||||
},
|
||||
// 2. Save Review
|
||||
const saveMutation = `#graphql
|
||||
mutation create($m: MetaobjectCreateInput!) { metaobjectCreate(metaobject: $m) { metaobject { id } userErrors { field message } } }
|
||||
`;
|
||||
|
||||
const saveResponse = await admin.graphql(saveMutation, {
|
||||
variables: {
|
||||
m: {
|
||||
type: "product_review",
|
||||
fields: [
|
||||
{ key: "product_id", value: String(productId) },
|
||||
{ key: "customer_name", value: String(name) },
|
||||
{ key: "content", value: String(review) },
|
||||
{ key: "rating", value: "5" }
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const result = await saveResponse.json();
|
||||
console.log(">>> [DEBUG] Save Result:", JSON.stringify(result));
|
||||
|
||||
// Check for "No definition" error
|
||||
const isMissing = result.errors?.some(e => e.message.toLowerCase().includes("metaobject definition")) ||
|
||||
result.data?.metaobjectCreate === null;
|
||||
|
||||
if (isMissing) {
|
||||
console.log(">>> [DEBUG] Action: Definition missing. Creating structure...");
|
||||
const createResult = await ensureDefinition(admin);
|
||||
const creationError = createResult.data?.metaobjectDefinitionCreate?.userErrors?.[0]?.message;
|
||||
|
||||
if (creationError && !creationError.includes("already exists")) {
|
||||
return jsonResponse({ error: "Storefront permissions need update. Please open the App Admin page once to fix." });
|
||||
}
|
||||
return jsonResponse({ error: "SUCCESS: Database structure created! Please click Submit Review one last time to save your review." });
|
||||
}
|
||||
|
||||
const errors = result.data?.metaobjectCreate?.userErrors;
|
||||
if (errors && errors.length > 0) {
|
||||
return jsonResponse({ error: errors[0].message });
|
||||
}
|
||||
|
||||
console.log(">>> [DEBUG] Action: Review Saved Successfully!");
|
||||
return jsonResponse({ success: true });
|
||||
} catch (err) {
|
||||
console.error(">>> [ERROR] Action Exception:", err.message);
|
||||
return jsonResponse({ error: "Connection issue. Please refresh the page and try again." });
|
||||
}
|
||||
};
|
||||
@ -1,5 +1,4 @@
|
||||
import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
|
||||
export const action = async ({ request }) => {
|
||||
const { payload, session, topic, shop } = await authenticate.webhook(request);
|
||||
@ -8,14 +7,7 @@ export const action = async ({ request }) => {
|
||||
const current = payload.current;
|
||||
|
||||
if (session) {
|
||||
await db.session.update({
|
||||
where: {
|
||||
id: session.id,
|
||||
},
|
||||
data: {
|
||||
scope: current.toString(),
|
||||
},
|
||||
});
|
||||
// In memory storage, we don't need to manually update the scope in a DB
|
||||
}
|
||||
|
||||
return new Response();
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
|
||||
export const action = async ({ request }) => {
|
||||
const { shop, session, topic } = await authenticate.webhook(request);
|
||||
@ -9,7 +8,8 @@ export const action = async ({ request }) => {
|
||||
// Webhook requests can trigger multiple times and after an app has already been uninstalled.
|
||||
// If this webhook already ran, the session may have been deleted previously.
|
||||
if (session) {
|
||||
await db.session.deleteMany({ where: { shop } });
|
||||
// Session cleanup for memory storage is not strictly required here
|
||||
// but you would normally call shopify.sessionStorage.deleteSessions([shop])
|
||||
}
|
||||
|
||||
return new Response();
|
||||
|
||||
@ -4,8 +4,7 @@ import {
|
||||
AppDistribution,
|
||||
shopifyApp,
|
||||
} from "@shopify/shopify-app-react-router/server";
|
||||
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
|
||||
import prisma from "./db.server";
|
||||
import { MemorySessionStorage } from "@shopify/shopify-app-session-storage-memory";
|
||||
|
||||
const shopify = shopifyApp({
|
||||
apiKey: process.env.SHOPIFY_API_KEY,
|
||||
@ -14,7 +13,7 @@ const shopify = shopifyApp({
|
||||
scopes: process.env.SCOPES?.split(","),
|
||||
appUrl: process.env.SHOPIFY_APP_URL || "",
|
||||
authPathPrefix: "/auth",
|
||||
sessionStorage: new PrismaSessionStorage(prisma),
|
||||
sessionStorage: new MemorySessionStorage(),
|
||||
distribution: AppDistribution.AppStore,
|
||||
future: {
|
||||
expiringOfflineAccessTokens: true,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user