Lot Expiration Alerts
Status: Approved Service: eli-kpi Related: ShipBob Fulfillment KPIs, ShipBob Integration Pattern: Same idempotent Slack notification pattern as App Store Review Notifier
Why This Exists
Eli Health ships cortisol test kits from two ShipBob warehouses (Elwood IL and Brampton ON). Each kit is tracked by manufacturing lot number with an expiration date. If a lot sits in a warehouse past its expiration, those units cannot be shipped — they become waste.
Manufacturing follows FIFO (First In, First Out): the oldest lot should always ship first. But without proactive alerting, lots can be overlooked. This system alerts the team on Slack when a lot enters its 90-day expiration window, giving enough time to prioritize shipping, transfer between warehouses, or trigger Klaviyo campaigns to encourage customers to use their existing tests.
Example scenario:
- Lot CB0016 has 28 units in Elwood (IL), expiring 2026-11-17
- Today is 2026-08-19 (90 days before expiry)
- Alert fires once: "Lot CB0016 expires in 90 days, 28 units still in Elwood"
- Team contacts ShipBob or adjusts marketing to move those units
How It Works
Step-by-Step Process
- Cloud Scheduler fires
POST /alerts/lot-expirationat 8:00 AM EST daily - Query BigQuery for all lots with
onhand_quantity > 0andexpiration_datewithin 90 days of today - Read Slack channel history for
#alerts-shipbob-inventoryto find all previously posted lot alerts - Extract identifiers from context blocks (pattern:
lot_alert:CB0016:2026-11-17) - Filter to only lots that haven't been posted yet
- Post each new expiring lot as a separate Slack message with Block Kit formatting
- Return summary: total expiring lots, already alerted, newly posted
Idempotent Deduplication
This follows the same pattern as the App Store Review Notifier (appstore-notifier.ts): Slack channel history is the deduplication database.
Why Slack History Instead of a BigQuery State Table?
| Approach | Pros | Cons |
|---|---|---|
| Slack history (chosen) | No extra table, single source of truth, survives app restarts, same proven pattern as app review alerts | Slightly slower (reads channel history), O(n) on channel size |
| BigQuery state table | Fast lookup, structured data | Extra table to manage, can drift from Slack reality, dual source of truth |
Since lot alerts are low volume (at most 5-10 lots ever), reading Slack channel history is fast and keeps the implementation consistent with existing patterns.
Deduplication Key
Each Slack message embeds a unique identifier in its context block:
lot_alert:{lot_number}:{expiration_date}
Example: lot_alert:CB0016:2026-11-17
The key includes both lot number and expiration date because the same lot number can appear with different expiration dates (different manufacturing runs of the same batch ID).
Reading Previously Posted Alerts
// Pattern: same as getPostedReviewIds() in appstore-notifier.ts
async function getPostedLotAlerts(): Promise<Set<string>> {
const client = getSlackClient();
const alertKeys = new Set<string>();
let cursor: string | undefined;
do {
const result = await client.conversations.history({
channel: LOT_ALERT_CHANNEL_ID,
limit: 200,
cursor,
});
for (const msg of result.messages || []) {
if (msg.blocks) {
for (const block of msg.blocks) {
if (block.type === 'context' && block.elements) {
for (const el of (block as any).elements) {
const text = el.text || '';
const match = text.match(/lot_alert:([^\s|]+)/);
if (match) {
alertKeys.add(match[1]);
}
}
}
}
}
// Fallback: check plain text
if (msg.text) {
const match = msg.text.match(/lot_alert:([^\s|]+)/);
if (match) {
alertKeys.add(match[1]);
}
}
}
cursor = result.response_metadata?.next_cursor;
} while (cursor);
return alertKeys;
}
BigQuery Query
Query to find lots approaching expiration:
-- Lots expiring within 90 days that still have stock
SELECT
i.id as inventory_id,
JSON_VALUE(i.data, '$.name') as item_name,
JSON_VALUE(lot, '$.lot_number') as lot_number,
JSON_VALUE(lot, '$.expiration_date') as expiration_date,
DATE_DIFF(
DATE(SAFE.PARSE_TIMESTAMP(
'%Y-%m-%dT%H:%M:%E*SZ',
JSON_VALUE(lot, '$.expiration_date')
)),
CURRENT_DATE(),
DAY
) as days_until_expiry,
CAST(JSON_VALUE(lot, '$.onhand_quantity') AS INT64) as onhand_quantity,
CAST(JSON_VALUE(lot, '$.fulfillable_quantity') AS INT64) as fulfillable_quantity,
CAST(JSON_VALUE(lot, '$.committed_quantity') AS INT64) as committed_quantity,
-- Which fulfillment centers hold this lot
ARRAY(
SELECT AS STRUCT
JSON_VALUE(fc, '$.name') as fc_name,
CAST(JSON_VALUE(fc, '$.onhand_quantity') AS INT64) as onhand,
CAST(JSON_VALUE(fc, '$.fulfillable_quantity') AS INT64) as fulfillable
FROM UNNEST(JSON_QUERY_ARRAY(
lot, '$.fulfillable_quantity_by_fulfillment_center'
)) as fc
WHERE CAST(JSON_VALUE(fc, '$.onhand_quantity') AS INT64) > 0
) as locations_with_stock
FROM `eli-health-prod.eli_health_shipbob.inventory` i,
UNNEST(JSON_QUERY_ARRAY(i.data, '$.fulfillable_quantity_by_lot')) as lot
WHERE CAST(JSON_VALUE(i.data, '$.is_active') AS BOOL) = true
AND CAST(JSON_VALUE(i.data, '$.is_lot') AS BOOL) = true
AND CAST(JSON_VALUE(lot, '$.onhand_quantity') AS INT64) > 0
-- Expiring within 90 days
AND DATE_DIFF(
DATE(SAFE.PARSE_TIMESTAMP(
'%Y-%m-%dT%H:%M:%E*SZ',
JSON_VALUE(lot, '$.expiration_date')
)),
CURRENT_DATE(),
DAY
) <= 90
-- Not already expired
AND DATE(SAFE.PARSE_TIMESTAMP(
'%Y-%m-%dT%H:%M:%E*SZ',
JSON_VALUE(lot, '$.expiration_date')
)) > CURRENT_DATE()
ORDER BY DATE(SAFE.PARSE_TIMESTAMP(
'%Y-%m-%dT%H:%M:%E*SZ',
JSON_VALUE(lot, '$.expiration_date')
)) ASC
SQL File: eli-kpi/src/sql/bigquery/shipbob/lot-expiration-alert.sql
Slack Message Format
Each expiring lot gets its own message, formatted with Block Kit:
┌─────────────────────────────────────────────────┐
│ ⚠️ *Lot Expiration Warning* │
│ │
│ *CB0016* — Cortisol Pack │
│ Expires *2026-11-17* (87 days remaining) │
│ │
│ 📦 *Stock:* │
│ Elwood (IL): 28 units on hand │
│ │
│ Action needed: prioritize shipping this lot │
│ or transfer to a higher-volume location. │
│ │
│ [📊 View Inventory Dashboard] │
│ │
│ lot_alert:CB0016:2026-11-17 │
└─────────────────────────────────────────────────┘
Block Kit Structure
function formatLotAlertBlocks(lot: ExpiringLot): any[] {
const daysText = lot.days_until_expiry <= 30
? `🔴 *${lot.days_until_expiry} days remaining*`
: lot.days_until_expiry <= 60
? `🟡 *${lot.days_until_expiry} days remaining*`
: `⚠️ *${lot.days_until_expiry} days remaining*`;
const locationLines = lot.locations_with_stock
.map(fc => ` ${fc.fc_name}: ${fc.onhand} units on hand (${fc.fulfillable} fulfillable)`)
.join('\n');
const blocks: any[] = [
{
type: 'header',
text: {
type: 'plain_text',
text: '⚠️ Lot Expiration Warning',
emoji: true,
},
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: [
`*${lot.lot_number}* — ${lot.item_name}`,
`Expires *${formatDate(lot.expiration_date)}* — ${daysText}`,
].join('\n'),
},
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `📦 *Stock by location:*\n${locationLines}`,
},
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: '_Action needed: prioritize shipping this lot or transfer to a higher-volume location._',
},
},
{
type: 'actions',
elements: [
{
type: 'button',
text: {
type: 'plain_text',
text: '📊 View Inventory Dashboard',
emoji: true,
},
url: 'https://kpi.eli.health/fulfillment',
action_id: `view_inventory_${lot.lot_number}`,
},
],
},
// Context block with deduplication key
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `${new Date().toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric',
})} | lot_alert:${lot.lot_number}:${lot.expiration_date.split('T')[0]}`,
},
],
},
];
return blocks;
}
Urgency Color Coding
| Days Until Expiry | Indicator | Meaning |
|---|---|---|
| 61–90 days | ⚠️ Yellow | Early warning — plan to ship |
| 31–60 days | 🟡 Amber | Attention needed — should be actively shipping |
| 1–30 days | 🔴 Red | Critical — must act immediately or lose stock |
Module Design
File Structure
eli-kpi/src/
├── lot-expiration-notifier.ts # NEW — alert logic
├── shipbob-routes.ts # MODIFY — add alert endpoint
├── shipbob-sync.ts # existing (no changes)
└── sql/bigquery/shipbob/
└── lot-expiration-alert.sql # NEW — BigQuery query
TypeScript Interface
export interface ExpiringLot {
inventory_id: number;
item_name: string;
lot_number: string;
expiration_date: string; // ISO 8601
days_until_expiry: number;
onhand_quantity: number;
fulfillable_quantity: number;
committed_quantity: number;
locations_with_stock: Array<{
fc_name: string;
onhand: number;
fulfillable: number;
}>;
}
export interface LotExpirationAlertResult {
total_expiring_lots: number; // lots matching the 90-day query
already_alerted: number; // lots already posted in Slack
newly_posted: number; // lots posted this run
failed: number; // lots that failed to post
lot_details: Array<{
lot_number: string;
expiration_date: string;
days_until_expiry: number;
onhand_quantity: number;
status: 'posted' | 'already_alerted' | 'failed';
}>;
}
Main Function
export async function checkLotExpirations(): Promise<LotExpirationAlertResult> {
// 1. Query BigQuery for lots expiring within 90 days
const expiringLots = await fetchExpiringLots();
console.log(`[Lot Expiration] Found ${expiringLots.length} lots expiring within 90 days`);
// 2. Read Slack channel history for previously posted alerts
const postedAlerts = await getPostedLotAlerts();
console.log(`[Lot Expiration] Found ${postedAlerts.size} lots already alerted in Slack`);
// 3. Filter to new lots only
const newLots = expiringLots.filter(lot => {
const key = `${lot.lot_number}:${lot.expiration_date.split('T')[0]}`;
return !postedAlerts.has(key);
});
console.log(`[Lot Expiration] ${newLots.length} new lots to alert`);
if (newLots.length === 0) {
return {
total_expiring_lots: expiringLots.length,
already_alerted: postedAlerts.size,
newly_posted: 0,
failed: 0,
lot_details: expiringLots.map(lot => ({
lot_number: lot.lot_number,
expiration_date: lot.expiration_date,
days_until_expiry: lot.days_until_expiry,
onhand_quantity: lot.onhand_quantity,
status: 'already_alerted' as const,
})),
};
}
// 4. Post new alerts (soonest expiry first)
const sorted = [...newLots].sort(
(a, b) => a.days_until_expiry - b.days_until_expiry
);
let posted = 0;
let failed = 0;
const details: LotExpirationAlertResult['lot_details'] = [];
for (const lot of sorted) {
const ok = await postLotAlert(lot);
if (ok) {
posted++;
details.push({ ...lotSummary(lot), status: 'posted' });
} else {
failed++;
details.push({ ...lotSummary(lot), status: 'failed' });
}
// Slack rate limit protection
await new Promise(resolve => setTimeout(resolve, 500));
}
return {
total_expiring_lots: expiringLots.length,
already_alerted: postedAlerts.size,
newly_posted: posted,
failed,
lot_details: details,
};
}
API Endpoints
Add to shipbob-routes.ts:
| Endpoint | Method | Description | Trigger |
|---|---|---|---|
/alerts/lot-expiration | POST | Check for expiring lots and send Slack alerts | Cloud Scheduler (daily) |
/alerts/lot-expiration/status | GET | Show current expiring lots and alert history | Manual / dashboard |
Route Implementation
// POST /alerts/lot-expiration — Check and alert on expiring lots
router.post('/alerts/lot-expiration', async (_req, res) => {
try {
console.log('Starting lot expiration check...');
const result = await checkLotExpirations();
res.json({ success: true, ...result });
} catch (error: any) {
console.error('Lot expiration alert error:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// GET /alerts/lot-expiration/status — Current expiring lots
router.get('/alerts/lot-expiration/status', async (_req, res) => {
try {
const expiringLots = await fetchExpiringLots();
const postedAlerts = await getPostedLotAlerts();
res.json({
success: true,
expiring_lots: expiringLots.length,
already_alerted: postedAlerts.size,
lots: expiringLots.map(lot => ({
...lot,
alert_status: postedAlerts.has(
`${lot.lot_number}:${lot.expiration_date.split('T')[0]}`
) ? 'alerted' : 'pending',
})),
});
} catch (error: any) {
res.status(500).json({ success: false, message: error.message });
}
});
Slack Channel Setup
Channel Configuration
| Setting | Value |
|---|---|
| Channel name | #alerts-shipbob-inventory |
| Channel ID | TBD (create channel, then update) |
| Visibility | Private (finance + operations team) |
| Bot | eli_health_alerts (same bot as other alert channels) |
Required Bot Permissions
The eli_health_alerts bot already has these scopes (used by App Store review alerts):
| Scope | Purpose |
|---|---|
channels:history / groups:history | Read channel history for deduplication |
chat:write | Post alert messages |
channels:read / groups:read | List channels |
No additional Slack configuration needed — same bot, same scopes, new channel.
Adding the Bot to the Channel
After creating #alerts-shipbob-inventory:
# Invite the bot to the channel
/invite @eli_health_alerts
Or via API:
curl -X POST "https://slack.com/api/conversations.join" \
-H "Authorization: Bearer $SLACK_BOT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel": "CHANNEL_ID"}'
Cloud Scheduler
Job Configuration
| Setting | Value |
|---|---|
| Name | lot-expiration-alert |
| Schedule | 0 13 * * * (8:00 AM EST = 13:00 UTC) |
| Timezone | America/New_York |
| Target | POST https://kpi.eli.health/alerts/lot-expiration |
| Timeout | 120s |
| Retries | 2 |
| Auth | OIDC token (KPI service account) |
Terraform Configuration
Add to eli-devops/tf/modules/regional/kpi-compute/main.tf alongside the existing ShipBob sync scheduler:
resource "google_cloud_scheduler_job" "lot_expiration_alert" {
name = "lot-expiration-alert"
description = "Daily check for manufacturing lots approaching expiration"
schedule = "0 13 * * *"
time_zone = "America/New_York"
region = var.region
http_target {
http_method = "POST"
uri = "${google_cloud_run_v2_service.kpi.uri}/alerts/lot-expiration"
oidc_token {
service_account_email = google_service_account.kpi.email
}
}
retry_config {
retry_count = 2
min_backoff_duration = "30s"
max_backoff_duration = "120s"
}
}
Sync Timeline (all times EST)
| Time (EST) | Time (UTC) | Job |
|---|---|---|
| 10:00 PM | 3:00 AM | ShipBob data sync (inventory, orders, etc.) |
| 11:00 PM | 4:00 AM | Loopwork sync |
| 12:00 AM | 5:00 AM | Videnglobe Ads sync |
| 8:00 AM | 13:00 UTC | Lot expiration alert (NEW) |
The alert runs 10 hours after the ShipBob sync, ensuring the inventory data is fresh from the overnight sync before checking expirations.
Edge Cases
Lot number reused across manufacturing runs
The same lot number (e.g., CB0016) can appear multiple times with different expiration dates — this happens when the same batch ID is used for a new manufacturing run. The deduplication key includes both lot number AND expiration date (lot_alert:CB0016:2026-11-17) to handle this correctly.
Lot stock drops to zero before alert fires
If a lot is fully shipped before it enters the 90-day window, no alert is needed. The query filters on onhand_quantity > 0, so fully depleted lots are automatically excluded.
Lot expires while still in stock
If a lot passes its expiration date with stock remaining, the query excludes it (filter: expiration_date > CURRENT_DATE()). A separate "expired stock" report could be added later to flag lots that expired with unsold inventory.
Slack channel doesn't exist yet
The alert will fail with a channel_not_found error. The endpoint returns this as a 500 with a clear error message. The channel must be created and the bot invited before the first run.
ShipBob sync hasn't run yet
If the eli_health_shipbob.inventory table is empty, the query returns zero rows and no alerts fire. The endpoint returns total_expiring_lots: 0.
Multiple lots expiring on the same day
Each lot gets its own Slack message. There's a 500ms delay between posts to respect Slack rate limits. With at most 5-10 lots, this completes in under 5 seconds.
Testing Plan
Manual Testing
- Trigger manually:
POST /alerts/lot-expirationvia curl or dashboard - Verify query: Run the SQL directly in BigQuery to confirm which lots match
- First run: All matching lots should post to Slack
- Second run (idempotent): Same lots should NOT re-post; response shows
already_alerted - Status check:
GET /alerts/lot-expiration/statusshould show each lot's alert state
Test with Current Data
As of January 2026, lot CB0016 (expiry 2026-11-17) has 28 units in Elwood (IL). This is approximately 290 days from expiry — outside the 90-day window. To test before a real lot enters the window:
# Option 1: Temporarily change the window from 90 to 365 days for testing
# Option 2: Wait until a lot naturally enters the 90-day window
# Option 3: Add a query parameter to override the window for testing:
curl -X POST "https://kpi.eli.health/alerts/lot-expiration?window_days=365"
The window_days query parameter (default: 90) allows testing without modifying production thresholds.
Implementation Checklist
Phase 1: Core Module
- Create
eli-kpi/src/sql/bigquery/shipbob/lot-expiration-alert.sql - Create
eli-kpi/src/lot-expiration-notifier.ts-
fetchExpiringLots()— BigQuery query -
getPostedLotAlerts()— Slack channel history dedup -
postLotAlert()— Block Kit formatted message -
checkLotExpirations()— main orchestrator
-
- Add
ExpiringLotandLotExpirationAlertResultinterfaces
Phase 2: Routes & Integration
- Add
POST /alerts/lot-expirationtoshipbob-routes.ts - Add
GET /alerts/lot-expiration/statustoshipbob-routes.ts - Import
lot-expiration-notifier.tsin routes - Build and deploy to Cloud Run
Phase 3: Slack & Scheduler
- Create
#alerts-shipbob-inventorySlack channel - Invite
eli_health_alertsbot to the channel - Update channel ID constant in
lot-expiration-notifier.ts - Add Cloud Scheduler job via Terraform
- Test end-to-end with manual trigger
Phase 4: Validation
- Verify first run posts alerts for matching lots
- Verify second run skips already-posted lots (idempotent)
- Verify status endpoint shows correct state
- Verify Cloud Scheduler fires at 8:00 AM EST
- Monitor first week of automated alerts
Revision History
| Date | Author | Change |
|---|---|---|
| 2026-01-30 | Chip | Initial technical spec |