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";
|
||||||
|
|
||||||
// GET → fetch reviews
|
const jsonResponse = (data, status = 200) => {
|
||||||
export const loader = async ({ request }) => {
|
return new Response(JSON.stringify(data), {
|
||||||
const url = new URL(request.url);
|
status,
|
||||||
const productId = url.searchParams.get("productId");
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
if (!productId) {
|
"Access-Control-Allow-Origin": "*",
|
||||||
return new Response("Product ID missing", { status: 400 });
|
"Cache-Control": "no-store",
|
||||||
}
|
},
|
||||||
|
|
||||||
const reviews = await prisma.review.findMany({
|
|
||||||
where: { productId },
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(reviews), {
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// POST → save review
|
async function ensureDefinition(admin) {
|
||||||
export const action = async ({ request }) => {
|
console.log(">>> [DEBUG] Triggering Metaobject Definition Creation...");
|
||||||
const formData = await request.formData();
|
const mutation = `#graphql
|
||||||
|
mutation CreateMetaobjectDefinition($definition: MetaobjectDefinitionCreateInput!) {
|
||||||
|
metaobjectDefinitionCreate(definition: $definition) {
|
||||||
|
metaobjectDefinition { type }
|
||||||
|
userErrors { field message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
const name = formData.get("name");
|
const variables = {
|
||||||
const review = formData.get("review");
|
definition: {
|
||||||
const productId = formData.get("productId");
|
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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!productId) {
|
const response = await admin.graphql(mutation, { variables });
|
||||||
return new Response("Product ID missing", { status: 400 });
|
const result = await response.json();
|
||||||
|
console.log(">>> [DEBUG] Creation Result:", JSON.stringify(result));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 = (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 jsonResponse(reviews);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(">>> [ERROR] Loader:", err.message);
|
||||||
|
return jsonResponse([]);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
await prisma.review.create({
|
export const action = async ({ request }) => {
|
||||||
data: {
|
try {
|
||||||
productId,
|
console.log(">>> [DEBUG] Action: Review Submission Started");
|
||||||
name,
|
const { admin, session } = await authenticate.public.appProxy(request);
|
||||||
review,
|
const url = new URL(request.url);
|
||||||
},
|
const customerId = url.searchParams.get("logged_in_customer_id");
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({ success: true }), {
|
if (!customerId) return jsonResponse({ error: "Please log in to leave a review." });
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
const formData = await request.formData();
|
||||||
|
const productId = formData.get("productId");
|
||||||
|
const name = formData.get("name");
|
||||||
|
const review = formData.get("review");
|
||||||
|
|
||||||
|
// 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)" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 { authenticate } from "../shopify.server";
|
||||||
import db from "../db.server";
|
|
||||||
|
|
||||||
export const action = async ({ request }) => {
|
export const action = async ({ request }) => {
|
||||||
const { payload, session, topic, shop } = await authenticate.webhook(request);
|
const { payload, session, topic, shop } = await authenticate.webhook(request);
|
||||||
@ -8,14 +7,7 @@ export const action = async ({ request }) => {
|
|||||||
const current = payload.current;
|
const current = payload.current;
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
await db.session.update({
|
// In memory storage, we don't need to manually update the scope in a DB
|
||||||
where: {
|
|
||||||
id: session.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
scope: current.toString(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response();
|
return new Response();
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
import db from "../db.server";
|
|
||||||
|
|
||||||
export const action = async ({ request }) => {
|
export const action = async ({ request }) => {
|
||||||
const { shop, session, topic } = await authenticate.webhook(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.
|
// 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 this webhook already ran, the session may have been deleted previously.
|
||||||
if (session) {
|
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();
|
return new Response();
|
||||||
|
|||||||
@ -4,8 +4,7 @@ import {
|
|||||||
AppDistribution,
|
AppDistribution,
|
||||||
shopifyApp,
|
shopifyApp,
|
||||||
} from "@shopify/shopify-app-react-router/server";
|
} from "@shopify/shopify-app-react-router/server";
|
||||||
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
|
import { MemorySessionStorage } from "@shopify/shopify-app-session-storage-memory";
|
||||||
import prisma from "./db.server";
|
|
||||||
|
|
||||||
const shopify = shopifyApp({
|
const shopify = shopifyApp({
|
||||||
apiKey: process.env.SHOPIFY_API_KEY,
|
apiKey: process.env.SHOPIFY_API_KEY,
|
||||||
@ -14,7 +13,7 @@ const shopify = shopifyApp({
|
|||||||
scopes: process.env.SCOPES?.split(","),
|
scopes: process.env.SCOPES?.split(","),
|
||||||
appUrl: process.env.SHOPIFY_APP_URL || "",
|
appUrl: process.env.SHOPIFY_APP_URL || "",
|
||||||
authPathPrefix: "/auth",
|
authPathPrefix: "/auth",
|
||||||
sessionStorage: new PrismaSessionStorage(prisma),
|
sessionStorage: new MemorySessionStorage(),
|
||||||
distribution: AppDistribution.AppStore,
|
distribution: AppDistribution.AppStore,
|
||||||
future: {
|
future: {
|
||||||
expiringOfflineAccessTokens: true,
|
expiringOfflineAccessTokens: true,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user