feat: add reviews api routes using shopify metaobjects and app proxy authentication

This commit is contained in:
Divya Pahuja 2026-03-10 03:01:51 +05:30
parent 88b44085e6
commit c91cffab72
4 changed files with 173 additions and 49 deletions

View File

@ -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." });
}
};

View File

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

View File

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

View File

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