Keep Authors Aware of Product Freshness

Official(made by Sanity team)

By Kevin Green

Surface product age to help editors choose the freshest inventory

schemaTypes/page.ts

// Existing schema types

defineField({
      name: 'modules',
      type: 'array',
      description: 'Editorial modules to associate with this collection',
      of: [
        defineArrayMember({type: 'grid'}),
        // Additional types can be added
      ],
      group: 'editorial',
    }),
    
defineField({
  name: 'productAgeAnalysis',
  title: 'Product Age Analysis',
  type: 'array',
  of: [
    {
      type: 'object',
      fields: [
        {
          name: 'product',
          title: 'Product',
          type: 'reference',
          to: [{type: 'product'}],
        },
        {
          name: 'ageInDays',
          title: 'Age in Days',
          type: 'number',
        },
        {
          name: 'updatedAgeInDays',
          title: 'Days Since Last Update',
          type: 'number',
        },
        {
          name: 'isOld',
          title: 'Is Stale Product',
          type: 'boolean',
        },
        {
          name: 'createdAt',
          title: 'Created At',
          type: 'datetime',
        },
        {
          name: 'lastUpdated',
          title: 'Last Updated',
          type: 'datetime',
        },
      ],
      preview: {
            select: {
              title: 'product.title',
              productTitle: 'product.store.title',
              ageInDays: 'ageInDays',
              updatedAgeInDays: 'updatedAgeInDays',
              isOld: 'isOld',
              createdAt: 'createdAt',
              lastUpdated: 'lastUpdated',
            },
            prepare({title, productTitle, ageInDays, updatedAgeInDays, isOld}) {
              const displayTitle = productTitle || title || 'Untitled Product'
              const createdAgeText = ageInDays ? `${ageInDays}d old` : 'Unknown age'
              const updatedAgeText = updatedAgeInDays
                ? `${updatedAgeInDays}d since update`
                : 'Unknown'
              const status = isOld ? '🔴 OLD' : '🟢 FRESH'

              return {
                title: displayTitle,
                subtitle: `${status} - Created: ${createdAgeText} | Updated: ${updatedAgeText}`,
              }
            },
          },
    },
  ],
  description: 'Automatically populated by the stale-products function',
  readOnly: true,
}),

sanity.blueprint.ts

import {defineBlueprint, defineDocumentFunction} from '@sanity/blueprints'

export default defineBlueprint({
  resources: [
    defineDocumentFunction({
      type: 'sanity.function.document',
      name: 'stale-products',
      memory: 1,
      timeout: 30,
      src: './functions/stale-products',
      event: {
        on: ['create', 'update'],
        filter: "_type == 'page' && delta::changedAny(modules)",
        projection: '{_id, _type, modules}',
      },
    }),
  ],
})

functions/stale-products/index.ts

import {documentEventHandler} from '@sanity/functions'
import {createClient} from '@sanity/client'

interface PagePayload {
  _id: string
  _type: string
  modules?: Array<{
    _type: string
    items?: Array<{
      _type: string
      productWithVariant?: {
        product?: {
          _id: string
        }
      }
    }>
  }>
}

interface ProductWithDates {
  _id: string
  _createdAt: string
  _updatedAt: string
  title?: string
  store?: {
    title?: string
  }
}

const STALE_PRODUCT_THRESHOLD_DAYS = 30

export const handler = documentEventHandler(async ({context, event}) => {
    console.log('📄 Page Product Age Analysis Function called at', new Date().toISOString())
    console.log('📝 Event:', event)

    try {
      const {_id, _type} = event.data as PagePayload

      if (_type !== 'page') {
        console.log('⏭️ Skipping non-page document:', _type)
        return
      }

      const client = createClient({
        ...context.clientOptions,
        useCdn: false,
        apiVersion: '2025-06-01',
      })

      console.log('🔍 Analyzing product ages for page:', _id)

      // Fetch the page with its modules and product data in one query
      // We're intentionally showing a more complex query to demonstrate real world usage,
      // you may have additional modules/fields with product references you'd want to analyze
      const page = await client.fetch<{
        _id: string
        modules: Array<{
          _type: string
          items?: Array<{
            _type: string
            productWithVariant?: {
              product?: {
                _id: string
                _createdAt: string
                _updatedAt: string
                title?: string
                store?: {
                  title?: string
                }
              }
            }
          }>
        }>
      }>(
        `*[_id == $id][0] {
      _id,
      modules[] {
        _type,
        (_type == 'grid') => {
          items[] {
            _type,
            (_type == 'productReference') => {
              productWithVariant {
                product-> {
                  _id,
                  _createdAt,
                  _updatedAt,
                  title,
                  store {
                    title
                  }
                }
              }
            }
          }
        }
      }
    }`,
        {id: _id},
      )

      if (!page) {
        console.log('❌ Page not found:', _id)
        return
      }

      // Extract product data directly from the query result
      const products: ProductWithDates[] = []

      page.modules?.forEach((module) => {
        if (module._type === 'grid' && module.items) {
          module.items.forEach((item) => {
            if (item._type === 'productReference' && item.productWithVariant?.product) {
              const product = item.productWithVariant.product
              if (product._id && product._createdAt && product._updatedAt) {
                products.push({
                  _id: product._id,
                  _createdAt: product._createdAt,
                  _updatedAt: product._updatedAt,
                  title: product.title,
                  store: product.store,
                })
              }
            }
          })
        }
      })

      // Remove duplicates based on product ID
      const productMap = new Map<string, ProductWithDates>()
      products.forEach((product) => {
        productMap.set(product._id, product)
      })
      const uniqueProducts = Array.from(productMap.values())

      console.log(
        '📦 Found products:',
        uniqueProducts.map((p) => p._id),
      )

      if (uniqueProducts.length === 0) {
        console.log('ℹ️ No product references found in page modules')
        // Clear existing product age analysis
        await client
          .patch(_id, {
            set: {
              productAgeAnalysis: [],
            },
          })
          .commit()
        return
      }

      console.log('📊 Retrieved product data for', uniqueProducts.length, 'products')

      // Calculate age analysis
      const now = new Date()
      const productAgeAnalysis = uniqueProducts.map((product) => {
        const createdAt = new Date(product._createdAt)
        const updatedAt = new Date(product._updatedAt)

        // Primary age calculation based on creation date (more important)
        const createdAgeInDays = Math.floor(
          (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24),
        )
        // Secondary age calculation based on last update
        const updatedAgeInDays = Math.floor(
          (now.getTime() - updatedAt.getTime()) / (1000 * 60 * 60 * 24),
        )

        // Flag as old if created more than 30 days ago
        const isOld = createdAgeInDays > STALE_PRODUCT_THRESHOLD_DAYS

        return {
          _key: `product-age-${product._id}`,
          product: {
            _ref: product._id,
            _type: 'reference',
          },
          lastUpdated: product._updatedAt,
          createdAt: product._createdAt,
          ageInDays: createdAgeInDays, // Primary age based on creation
          updatedAgeInDays, // Secondary age based on last update
          isOld,
        }
      })

      // Sort by age (oldest first)
      productAgeAnalysis.sort((a, b) => b.ageInDays - a.ageInDays)

      console.log('📈 Product age analysis:', {
        totalProducts: productAgeAnalysis.length,
        oldProducts: productAgeAnalysis.filter((p) => p.isOld).length,
        averageAge: Math.round(
          productAgeAnalysis.reduce((sum, p) => sum + p.ageInDays, 0) / productAgeAnalysis.length,
        ),
      })

      // Update the page with the product age analysis
      await client
        .patch(_id, {
          set: {
            productAgeAnalysis,
          },
        })
        .commit()

      console.log('✅ Page product age analysis completed:', {
        pageId: _id,
        productsAnalyzed: productAgeAnalysis.length,
        oldProductsFound: productAgeAnalysis.filter((p) => p.isOld).length,
      })
    } catch (error) {
      console.error('❌ Error processing product:', error)
      throw error
    }
  },
)

The Problem: Products sitting unsold become a drain on your business—tying up capital, taking valuable warehouse space, and eventually becoming obsolete inventory you'll need to write off. Without visibility into slow-moving stock, you can't take action to move it before it loses all value. Manual tracking across multiple systems is time-consuming and error-prone.

The Solution: This function automatically identifies and flags stale products based on your custom criteria—whether it's time since last sale, inventory age, or seasonal relevance. Get alerts on products that need attention before they become dead stock, enabling you to run promotions, adjust pricing, or make strategic decisions to protect your margins.

Quick Start

View full instructions and source code at github.com/sanity-io/sanity/tree/feature/stale-products-functions/examples/functions/stale-products

Initialize blueprints if you haven't already:
npx sanity blueprints init

Add the function to your project:
npx sanity blueprints add function --example stale-products

Deploy to production:
npx sanity blueprints deploy

How It Works

The function automatically monitors your product catalog and:

  • Triggers on scheduled intervals or product updates
  • Evaluates products against your staleness criteria
  • Flags products that haven't sold within your threshold
  • Updates product documents with staleness status
  • Can trigger notifications or automated workflows

Key Benefits

  • Prevent dead stock - Catch slow-moving inventory before it becomes obsolete
  • Protect margins - Take action while products still have value
  • Free up capital - Identify items to discount and convert to cash
  • Optimize storage - Clear warehouse space for profitable inventory
  • Automate monitoring - No more manual spreadsheet tracking
  • Custom thresholds - Define staleness based on your business needs

Perfect For

  • E-commerce operations managing large product catalogs
  • Seasonal businesses needing to clear inventory before it's out of season
  • Fashion and apparel where products quickly lose relevance
  • Perishable goods requiring time-sensitive monitoring

For complete implementation details and advanced configuration options, visit the GitHub repository.

Contributor

Official Recipes by Sanity

First Published Timestamp Function

Featured contribution
Official(made by Sanity team)

Automatically track when content was first published with a timestamp that sets once and never overwrites, providing reliable publication history for analytics and editorial workflows.

Knut Melvær
Go to First Published Timestamp Function

Automatically tag blog posts

Featured contribution
Official(made by Sanity team)

AI-powered automatic tagging for Sanity blog posts that analyzes content to generate 3 relevant tags, maintaining consistency by reusing existing tags from your content library.

Go to Automatically tag blog posts