import express from "express";
import shopify from "../shopify.js";
import {
listGroups,
getGroup,
createGroup,
updateGroup,
deleteGroup,
getGroupedProductIds,
} from "../db.js";
import { detectGroups, suggestStyle } from "../auto-group.js";
import { syncGroupMetafields, clearGroupMetafields } from "../metafields.js";
// Keep the storefront-facing metafield in sync with a group's DB state:
// published -> write to members; otherwise -> clear from members.
async function syncGroup(session, group, removedProductIds = []) {
if (removedProductIds.length) {
await clearGroupMetafields(session, removedProductIds);
}
if (group && group.status === "published") {
await syncGroupMetafields(session, group);
} else if (group) {
await clearGroupMetafields(
session,
(group.members || []).map((m) => m.product_id)
);
}
}
/**
* Admin API, mounted under /api and protected by validateAuthenticatedSession.
* The authenticated shop is always on res.locals.shopify.session.shop.
*/
const router = express.Router();
const shopOf = (res) => res.locals.shopify.session.shop;
/* ---------- Products list / search (for the group picker) ---------- */
router.get("/products", async (req, res) => {
const client = new shopify.api.clients.Graphql({
session: res.locals.shopify.session,
});
const term = (req.query.q || "").toString();
const after = req.query.after ? `, after: "${req.query.after}"` : "";
const query = term ? `title:*${term}*` : "";
const data = await client.request(
`query ($q: String!) {
products(first: 25, query: $q${after}) {
pageInfo { hasNextPage endCursor }
edges { node {
id
title
handle
featuredImage { url }
} }
}
}`,
{ variables: { q: query } }
);
const conn = data.data.products;
res.json({
products: conn.edges.map((e) => ({
id: e.node.id,
title: e.node.title,
handle: e.node.handle,
image: e.node.featuredImage?.url || "",
})),
pageInfo: conn.pageInfo,
});
});
/* ---------- Product groups (combined-listing swatches) ---------- */
router.get("/groups", async (_req, res) => {
res.json({ groups: await listGroups(shopOf(res)) });
});
router.get("/groups/:id", async (req, res) => {
const group = await getGroup(shopOf(res), req.params.id);
if (!group) return res.status(404).json({ error: "not found" });
res.json({ group });
});
router.post("/groups", async (req, res) => {
if (!req.body.name?.trim())
return res.status(422).json({ errors: ["name is required"] });
const group = await createGroup(shopOf(res), req.body);
await syncGroup(res.locals.shopify.session, group);
res.status(201).json({ group });
});
router.put("/groups/:id", async (req, res) => {
const existing = await getGroup(shopOf(res), req.params.id);
if (!existing) return res.status(404).json({ error: "not found" });
const group = await updateGroup(shopOf(res), req.params.id, req.body);
// Members removed in this edit must have their metafield cleared.
const newIds = new Set((group.members || []).map((m) => m.product_id));
const removed = (existing.members || [])
.map((m) => m.product_id)
.filter((id) => !newIds.has(id));
await syncGroup(res.locals.shopify.session, group, removed);
res.json({ group });
});
router.delete("/groups/:id", async (req, res) => {
const existing = await getGroup(shopOf(res), req.params.id);
if (existing) {
// Clear metafields BEFORE removing the DB rows, so we still know the members.
await clearGroupMetafields(
res.locals.shopify.session,
(existing.members || []).map((m) => m.product_id)
);
}
await deleteGroup(shopOf(res), req.params.id);
res.status(204).end();
});
// Clean up orphaned metafields: products that still carry our linked_products
// metafield but are no longer in any group (e.g. a group deleted before the
// delete bug was fixed). Scans the catalog via the metafield and clears them.
router.post("/groups/cleanup-orphans", async (req, res) => {
const shop = shopOf(res);
const client = new shopify.api.clients.Graphql({
session: res.locals.shopify.session,
});
const grouped = await getGroupedProductIds(shop);
const orphans = [];
let after = null;
for (let page = 0; page < 20; page++) {
const data = await client.request(
`query ($after: String) {
products(first: 100, after: $after) {
pageInfo { hasNextPage endCursor }
edges { node {
id
metafield(namespace: "linked_products", key: "group") { id }
} }
}
}`,
{ variables: { after } }
);
const conn = data.data.products;
for (const e of conn.edges) {
if (e.node.metafield && !grouped.has(e.node.id)) orphans.push(e.node.id);
}
if (!conn.pageInfo.hasNextPage) break;
after = conn.pageInfo.endCursor;
}
await clearGroupMetafields(res.locals.shopify.session, orphans);
res.json({ cleared: orphans.length });
});
// Force-rewrite metafields for a group (creates the definition if missing).
// Useful after upgrading, or if a group was published before the definition
// existed. Returns the metafield read back from the first member to confirm.
router.post("/groups/:id/resync", async (req, res) => {
const group = await getGroup(shopOf(res), req.params.id);
if (!group) return res.status(404).json({ error: "not found" });
await syncGroup(res.locals.shopify.session, group);
// Read it back so the UI can confirm Liquid will see it.
const client = new shopify.api.clients.Graphql({
session: res.locals.shopify.session,
});
const first = group.members?.[0];
let readback = null;
if (first) {
const data = await client.request(
`query ($id: ID!) {
product(id: $id) {
metafield(namespace: "linked_products", key: "group") {
type
value
definition { id access { storefront } }
}
}
}`,
{ variables: { id: first.product_id } }
);
readback = data.data.product?.metafield || null;
}
res.json({ ok: true, status: group.status, readback });
});
/* ---------- Auto-detect groups by shared title prefix ----------
* Fetches the catalog, detects sibling products, and creates any new groups as
* DRAFT (hidden from the storefront until the merchant publishes them). Skips
* products already in a group so re-running is safe.
*/
async function fetchAllProducts(session) {
const client = new shopify.api.clients.Graphql({ session });
const out = [];
let after = null;
// Cap at a few pages to stay well within rate limits for the MVP.
for (let page = 0; page < 10; page++) {
const data = await client.request(
`query ($after: String) {
products(first: 100, after: $after) {
pageInfo { hasNextPage endCursor }
edges { node { id title handle featuredImage { url } } }
}
}`,
{ variables: { after } }
);
const conn = data.data.products;
for (const e of conn.edges) {
out.push({
id: e.node.id,
title: e.node.title,
handle: e.node.handle,
image: e.node.featuredImage?.url || "",
});
}
if (!conn.pageInfo.hasNextPage) break;
after = conn.pageInfo.endCursor;
}
return out;
}
// Preview without saving (so the UI can show what WILL be created).
router.get("/groups/auto/preview", async (_req, res) => {
const shop = shopOf(res);
const products = await fetchAllProducts(res.locals.shopify.session);
const grouped = await getGroupedProductIds(shop);
const detected = detectGroups(products)
.map((g) => ({
...g,
members: g.members.filter((m) => !grouped.has(m.productId)),
}))
.filter((g) => g.members.length >= 2)
.map((g) => ({ ...g, style: suggestStyle(g) }));
res.json({ detected });
});
// Create all detected groups as drafts. Returns how many were created.
router.post("/groups/auto", async (req, res) => {
const shop = shopOf(res);
const products = await fetchAllProducts(res.locals.shopify.session);
const grouped = await getGroupedProductIds(shop);
const detected = detectGroups(products);
let created = 0;
for (const g of detected) {
const members = g.members.filter((m) => !grouped.has(m.productId));
if (members.length < 2) continue;
await createGroup(shop, {
name: g.base,
label: req.body?.label || "Color",
style: suggestStyle({ ...g, members }),
members,
status: "draft",
source: "auto",
});
created++;
}
res.json({ created, groups: await listGroups(shop) });
});
export default router;