Skip to main content

Shop (Creator Marketplace) — Technical Reference

← Back to Shop (Creator Marketplace)

Where this lives

Backend

Frontend

Note: this GraphQL layer is recent — the source comments it as "brand new (this session's marketplace build)" and not yet in the shared packages/graphql operations (generated into @closegram/apollo-web), so both page-components above define their GraphQL documents inline rather than importing generated hooks (the established pattern elsewhere in this codebase once a schema stabilizes).

Technical implementation checklist

  • myProducts / createProduct / updateProduct / setProductActive / deleteProduct — wired; ShopManagePage.tsx + CreateProductModal.tsx
  • sellerProducts / hasActiveShop — wired; PublicProfilePage.tsx Shop tab
  • purchaseProduct — wired; PurchaseProductModal.tsx
  • mySales / markOrderShipped / cancelOrder — wired; ShopManagePage.tsx
  • myOrders — wired; ShopManagePage.tsx (buyer's own purchase history tab)
  • Digital product delivery — Product.productType (physical | digital) is set at creation and cannot change afterward; Product.digitalDeliveryContent holds the download link/code/instructions, visible only to the seller or a buyer with a completed order (Product.digitalDeliveryContent field resolver in product.resolver.js). ProductPurchaseInput.shippingAddress is now optional — required for physical purchases, ignored for digital ones. A digital ProductOrder is created with status: delivered immediately (no pendingshipped step).
  • Product reviews / ratings — ProductReview (rating 1-5, optional comment), one per buyer per product (DB unique index on (buyer_id, product_id)), gated on the buyer having a shipped/delivered order for that product. Product.averageRating / Product.reviewCount are computed on read (ProductReviewAccessService#getSummary), not stored columns.
  • Formal dispute process — a buyer can openProductDispute on their own shipped/delivered order (one open dispute per order at a time); an admin with MODERATE_CONTENT permission resolves it via adminResolveProductDispute with resolved_refund (reverses the coin transfer, same buyer-credit/seller-debit pattern as cancelOrder) or resolved_denied (no coin movement). The order's own status is untouched by a dispute — it's the ProductDispute.status that tracks the outcome.

Listings (Product)

query MyProducts($limit: Int, $offset: Int) {
myProducts(limit: $limit, offset: $offset) {
id name description priceCoins stock images isActive salesCount
productType digitalDeliveryContent averageRating reviewCount createdAt
}
}

query SellerProducts($sellerId: ID!, $limit: Int, $offset: Int) {
sellerProducts(sellerId: $sellerId, limit: $limit, offset: $offset) {
id name priceCoins stock images salesCount productType averageRating reviewCount
}
}

query HasActiveShop($sellerId: ID!) { hasActiveShop(sellerId: $sellerId) }

mutation CreateProduct($input: ProductCreateInput!) { createProduct(input: $input) { id } }
mutation UpdateProduct($id: ID!, $input: ProductUpdateInput!) { updateProduct(id: $id, input: $input) { id } }
mutation SetProductActive($id: ID!, $isActive: Boolean!) { setProductActive(id: $id, isActive: $isActive) { id isActive } }
mutation DeleteProduct($id: ID!) { deleteProduct(id: $id) }

myProducts is the owner-facing list — it includes unpublished (isActive: false) and out-of-stock listings. sellerProducts is the public-facing equivalent shown on a profile's Shop tab — published listings only. hasActiveShop decides whether the Shop tab even renders on a given profile.

ProductCreateInput.productType (physical | digital, defaults to physical) is only settable at creation — updateProduct rejects changing it later (see the code comment in product.manager.js#updateProduct), since flipping it after the fact could leave a digital product without delivery content or a physical one with a stale one. stock is optional for a digital product (defaults to 999999 — effectively unlimited, since a downloadable file isn't inventory-constrained) but still required for a physical one. A digital product must set digitalDeliveryContent (the download link/code/instructions) at creation; it can be updated later via ProductUpdateInput.digitalDeliveryContent (still only on an already-digital product). Product.digitalDeliveryContent is only ever returned to the seller or to a buyer with a completed (shipped/delivered) order for that product — everyone else gets null, even if the underlying row has content (Product.digitalDeliveryContent field resolver in product.resolver.js).

Orders (ProductOrder)

mutation PurchaseProduct($input: ProductPurchaseInput!) {
purchaseProduct(input: $input) {
id totalPriceCoins platformFeeCoins sellerEarningsCoins status shippingAddress
}
}

query MyOrders($limit: Int, $offset: Int) {
myOrders(limit: $limit, offset: $offset) {
id product { name images productType digitalDeliveryContent } quantity totalPriceCoins status trackingCarrier trackingNumber trackingUrl createdAt
}
}

query MySales($status: String, $limit: Int, $offset: Int) {
mySales(status: $status, limit: $limit, offset: $offset) {
id buyer { username } quantity totalPriceCoins sellerEarningsCoins status shippingAddress buyerNote createdAt
}
}

mutation MarkOrderShipped($id: ID!, $input: MarkOrderShippedInput!) {
markOrderShipped(id: $id, input: $input) { id status trackingCarrier trackingNumber trackingUrl shippedAt }
}

mutation CancelOrder($id: ID!, $reason: String) {
cancelOrder(id: $id, reason: $reason) { id status canceledReason }
}

Coins move immediately at checkout (purchaseProduct) — platformFeeCoins is deducted from totalPriceCoins before crediting sellerEarningsCoins to the seller's balance. ProductOrderStatus is one of pending, shipped, delivered, or canceleddelivered is reached only by a digital order at the moment of purchase (there's no pendingdelivered transition for a physical order; those follow pendingshipped, same as before). ProductPurchaseInput.shippingAddress and ProductOrder.shippingAddress are both optional now (String, not String!) — required for a physical purchase, null for a digital one. mySales can be filtered by status to power the seller's fulfillment queue tabs.

Reviews (ProductReview)

query ProductReviews($productId: ID!, $limit: Int, $offset: Int) {
productReviews(productId: $productId, limit: $limit, offset: $offset) {
id rating comment buyer { username } createdAt
}
}

query ProductReviewSummary($productId: ID!) {
productReviewSummary(productId: $productId) { averageRating reviewCount }
}

query HasReviewedProduct($productId: ID!) { hasReviewedProduct(productId: $productId) }

mutation SubmitProductReview($orderId: ID!, $input: ProductReviewInput!) {
submitProductReview(orderId: $orderId, input: $input) { id rating comment }
}

mutation DeleteProductReview($id: ID!) { deleteProductReview(id: $id) }

submitProductReview takes an orderId, not a bare productId — the review's orderId foreign key requires one, and anchoring to a specific order lets the resolver verify that exact order actually reached shipped/delivered rather than trusting a client-supplied product ID. A buyer gets exactly one review per product (DB unique index on (buyer_id, product_id)) — calling submitProductReview again, even referencing a different order for the same product, updates the existing review rather than creating a second one.

Disputes (ProductDispute)

query OrderDisputes($orderId: ID!) {
orderDisputes(orderId: $orderId) { id status reason adminNotes createdAt }
}

# Admin only - requires MODERATE_CONTENT permission
query OpenProductDisputes($limit: Int, $offset: Int) {
adminOpenProductDisputes(limit: $limit, offset: $offset) {
id reason status createdAt
raisedBy { username }
order { id product { name } seller { username } totalPriceCoins }
}
}

mutation OpenProductDispute($orderId: ID!, $reason: String!) {
openProductDispute(orderId: $orderId, reason: $reason) { id status }
}

# Admin only - requires MODERATE_CONTENT permission
mutation ResolveProductDispute($disputeId: ID!, $resolution: ProductDisputeResolution!, $adminNotes: String) {
adminResolveProductDispute(disputeId: $disputeId, resolution: $resolution, adminNotes: $adminNotes) { id status }
}

A dispute can only be opened on an order that's already shipped/delivered (a still-pending order should use cancelOrder instead) and only one dispute can be open per order at a time (findOpenByOrder guard in product-dispute.manager.js#openDispute). adminResolveProductDispute requires context.admin plus the MODERATE_CONTENT permission (same gate as refundPostPurchase — see exclusive-posts.md). resolved_refund credits the buyer the full order.totalPriceCoins back and reverses the seller's sellerEarningsCoins, using the identical coin-reversal pattern as cancelOrder; it does not restore product stock or change the order's own status, since the order genuinely did ship — the refund is tracked on the ProductDispute row, not by mutating history. resolved_denied just closes the dispute with no coin movement. The seller gets a best-effort notification when a dispute opens against one of their sales, and the buyer gets one when it's resolved (dispute_opened / dispute_resolved — added to the notificationType whitelist in validators/notification.validator.js alongside the other product-marketplace notification types).