TTL Cleanup API + Client-Side Freshness Implementation Plan โ
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Expire stale lanterns, waves, and connections server-side via a new API endpoint, and refresh venue data client-side when the app returns to foreground.
Architecture: Three independent cleanup service functions (lanterns, waves, connections) orchestrated by a single /cleanup/expired POST endpoint in the lanterns API. Client-side visibilitychange listener in Dashboard re-fetches venue lanterns on foreground return.
Tech Stack: Express.js, Firebase Admin SDK, Firestore batch writes, forge analytics, React useEffect/visibilitychange
File Map โ
| Action | Path | Responsibility |
|---|---|---|
| Create | services/api/lanterns/src/services/cleanup.service.js | Three cleanup functions: lanterns, waves, connections |
| Create | services/api/lanterns/src/routes/cleanup.js | POST /expired route handler with scheduler/admin auth |
| Modify | services/api/lanterns/src/index.js | Register cleanup routes (no auth middleware) |
| Modify | services/api/lanterns/openapi.json | Add /cleanup/expired endpoint spec |
| Modify | apps/web/src/screens/dashboard/Dashboard.jsx | Add visibilitychange re-fetch for venue lanterns |
| Modify | services/functions/firebase/modules/lanternCleanup.js | Add deprecation comment |
Task 1: Cleanup Service โ Lanterns โ
Files:
Create:
services/api/lanterns/src/services/cleanup.service.js[ ] Step 1: Create cleanup.service.js with cleanupExpiredLanterns
/**
* Cleanup Service โ Server-side TTL enforcement
*
* Functions:
* - cleanupExpiredLanterns โ Expire stale active lanterns, reconcile venue counts
* - cleanupExpiredWaves โ Expire stale pending waves
* - cleanupExpiredConnections โ Expire stale active connections
*/
import admin from 'firebase-admin'
const db = admin.firestore()
/**
* Find active lanterns past their expiresAt, batch-update to extinguished,
* and reconcile activeLanternCount on affected venues.
*
* @returns {{ cleaned: number, venuesAffected: number }}
*/
export async function cleanupExpiredLanterns() {
const now = admin.firestore.Timestamp.now()
const snapshot = await db
.collection('lanterns')
.where('status', '==', 'active')
.where('expiresAt', '<=', now)
.get()
if (snapshot.empty) {
return { cleaned: 0, venuesAffected: 0 }
}
// Track affected venues for count reconciliation
const affectedVenueIds = new Set()
const batch = db.batch()
for (const doc of snapshot.docs) {
batch.update(doc.ref, {
status: 'extinguished',
extinguishedAt: now,
extinguishReason: 'expired',
})
const venueId = doc.data().venueId
if (venueId) {
affectedVenueIds.add(venueId)
}
}
await batch.commit()
// Reconcile activeLanternCount on affected venues by counting actual
// remaining active lanterns (corrects drift from TTL auto-deletions)
const venuePromises = Array.from(affectedVenueIds).map(async (venueId) => {
const activeSnap = await db
.collection('lanterns')
.where('venueId', '==', venueId)
.where('status', '==', 'active')
.get()
return db.collection('venues').doc(venueId).update({
activeLanternCount: activeSnap.size,
})
})
await Promise.allSettled(venuePromises)
return { cleaned: snapshot.size, venuesAffected: affectedVenueIds.size }
}- [ ] Step 2: Commit
git add services/api/lanterns/src/services/cleanup.service.js
git commit -m "feat(lanterns-api): add cleanupExpiredLanterns service function"Task 2: Cleanup Service โ Waves and Connections โ
Files:
Modify:
services/api/lanterns/src/services/cleanup.service.js[ ] Step 1: Add cleanupExpiredWaves and cleanupExpiredConnections
Append after the cleanupExpiredLanterns function:
/**
* Find pending waves past their expiresAt and batch-update to expired.
*
* @returns {{ cleaned: number }}
*/
export async function cleanupExpiredWaves() {
const now = admin.firestore.Timestamp.now()
const snapshot = await db
.collection('waves')
.where('status', '==', 'pending')
.where('expiresAt', '<=', now)
.get()
if (snapshot.empty) {
return { cleaned: 0 }
}
const batch = db.batch()
for (const doc of snapshot.docs) {
batch.update(doc.ref, { status: 'expired' })
}
await batch.commit()
return { cleaned: snapshot.size }
}
/**
* Find active connections past their expiresAt and batch-update to expired.
*
* @returns {{ cleaned: number }}
*/
export async function cleanupExpiredConnections() {
const now = admin.firestore.Timestamp.now()
const snapshot = await db
.collection('connections')
.where('status', '==', 'active')
.where('expiresAt', '<=', now)
.get()
if (snapshot.empty) {
return { cleaned: 0 }
}
const batch = db.batch()
for (const doc of snapshot.docs) {
batch.update(doc.ref, { status: 'expired' })
}
await batch.commit()
return { cleaned: snapshot.size }
}- [ ] Step 2: Commit
git add services/api/lanterns/src/services/cleanup.service.js
git commit -m "feat(lanterns-api): add cleanupExpiredWaves and cleanupExpiredConnections"Task 3: Cleanup Route โ
Files:
Create:
services/api/lanterns/src/routes/cleanup.js[ ] Step 1: Create the cleanup route
/**
* Cleanup Routes โ Server-side TTL enforcement
*
* POST /cleanup/expired โ Expire stale lanterns, waves, and connections
*
* Called by Cloud Scheduler on a cron. Also callable manually by admins.
* Auth: X-CloudScheduler-JobName header OR admin role (no Firebase token middleware).
*/
import { Router } from 'express'
import {
cleanupExpiredLanterns,
cleanupExpiredWaves,
cleanupExpiredConnections,
} from '../services/cleanup.service.js'
import { forge } from '@lantern/forge'
const router = Router()
/**
* POST /cleanup/expired
*
* Sweeps all three TTL-governed collections in one call.
*/
router.post('/expired', async (req, res, next) => {
try {
// Verify this is from Cloud Scheduler or an admin
const schedulerHeader = req.headers['x-cloudscheduler-jobname']
const isScheduler = !!schedulerHeader
const isAdmin = req.user?.role === 'admin'
if (!isScheduler && !isAdmin) {
return res.status(403).json({
error: 'FORBIDDEN',
message: 'This endpoint is for Cloud Scheduler or admin users only',
})
}
const source = isScheduler ? 'scheduler' : 'admin'
req.log.info({ source }, 'Starting TTL cleanup sweep')
// Run all three cleanups
const [lanterns, waves, connections] = await Promise.all([
cleanupExpiredLanterns(),
cleanupExpiredWaves(),
cleanupExpiredConnections(),
])
req.log.info({ lanterns, waves, connections }, 'TTL cleanup sweep completed')
// Analytics per collection
if (lanterns.cleaned > 0) {
forge.track({
serviceId: 'lanterns-api',
eventType: 'cleanup_lanterns',
entityType: 'lantern',
metadata: {
cleaned: lanterns.cleaned,
venuesAffected: lanterns.venuesAffected,
source,
},
}).catch(() => {})
}
if (waves.cleaned > 0) {
forge.track({
serviceId: 'lanterns-api',
eventType: 'cleanup_waves',
entityType: 'wave',
metadata: { cleaned: waves.cleaned, source },
}).catch(() => {})
}
if (connections.cleaned > 0) {
forge.track({
serviceId: 'lanterns-api',
eventType: 'cleanup_connections',
entityType: 'connection',
metadata: { cleaned: connections.cleaned, source },
}).catch(() => {})
}
res.json({
success: true,
lanterns,
waves,
connections,
})
} catch (error) {
next(error)
}
})
export default router- [ ] Step 2: Commit
git add services/api/lanterns/src/routes/cleanup.js
git commit -m "feat(lanterns-api): add POST /cleanup/expired route"Task 4: Register Cleanup Route in index.js โ
Files:
Modify:
services/api/lanterns/src/index.js[ ] Step 1: Add import
After the existing route imports (line 20, after import bonfireRoutes from './routes/bonfire.js'), add:
import cleanupRoutes from './routes/cleanup.js'- [ ] Step 2: Mount the route without auth middleware
After the health routes line (app.use('/', healthRoutes) at line 56) and before the API docs section, add:
// Cleanup routes (scheduler/admin auth handled internally, no Firebase token)
app.use('/cleanup', cleanupRoutes)- [ ] Step 3: Verify the route ordering
The final route order should be:
- Health routes (no auth) โ existing
- Cleanup routes (no Firebase token, internal auth) โ new
- API docs (no auth) โ existing
- Protected routes (Firebase auth) โ existing
- Error handler โ existing
- [ ] Step 4: Commit
git add services/api/lanterns/src/index.js
git commit -m "feat(lanterns-api): register /cleanup routes without token middleware"Task 5: Update OpenAPI Spec โ
Files:
Modify:
services/api/lanterns/openapi.json[ ] Step 1: Add Cleanup tag
In the tags array, add:
{ "name": "Cleanup", "description": "Server-side TTL enforcement for expired documents" }- [ ] Step 2: Add /cleanup/expired path
In the paths object, add:
"/cleanup/expired": {
"post": {
"tags": ["Cleanup"],
"summary": "Expire stale lanterns, waves, and connections",
"description": "Sweeps all TTL-governed collections. Called by Cloud Scheduler on a cron or manually by admins. Authenticated via X-CloudScheduler-JobName header or admin role โ no Firebase token required.",
"security": [],
"responses": {
"200": {
"description": "Cleanup completed",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CleanupResponse" }
}
}
},
"403": {
"description": "Not a scheduler or admin request",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
}
}
}
}- [ ] Step 3: Add CleanupResponse schema
In components.schemas, add:
"CleanupResponse": {
"type": "object",
"properties": {
"success": { "type": "boolean" },
"lanterns": {
"type": "object",
"properties": {
"cleaned": { "type": "integer" },
"venuesAffected": { "type": "integer" }
}
},
"waves": {
"type": "object",
"properties": {
"cleaned": { "type": "integer" }
}
},
"connections": {
"type": "object",
"properties": {
"cleaned": { "type": "integer" }
}
}
}
}- [ ] Step 4: Commit
git add services/api/lanterns/openapi.json
git commit -m "docs(lanterns-api): add /cleanup/expired to OpenAPI spec"Task 6: Client-Side visibilitychange Re-fetch โ
Files:
Modify:
apps/web/src/screens/dashboard/Dashboard.jsx[ ] Step 1: Add visibilitychange useEffect
Add this useEffect near the other subscription effects (around line 370, after the existing useEffect that sets up subscribeToActiveLanterns, subscribeToIncomingWaves, and subscribeToActiveConnections). Uses refs to avoid stale closures:
// Re-fetch venue lanterns when app returns to foreground
useEffect(() => {
let lastFetchTime = 0
const REFETCH_COOLDOWN_MS = 30_000 // 30 seconds
const handleVisibilityChange = async () => {
if (document.visibilityState !== 'visible') return
const now = Date.now()
if (now - lastFetchTime < REFETCH_COOLDOWN_MS) return
const venue = selectedVenueRef.current
const user = currentUserRef.current
if (!venue || !user) return
lastFetchTime = now
devLog('๐ App foregrounded โ refreshing venue lanterns')
try {
const venueLanterns = await getVenueLanterns(venue.id)
const formattedLanterns = venueLanterns.map((lantern) => ({
id: lantern.id,
userId: lantern.userId,
isSelf: lantern.userId === user.uid,
lanternName: lantern.lanternName || null,
mood: lantern.mood || 'Conversation',
interest: lantern.interest || 'Open to connect',
time: formatTimeRemaining(lantern),
profileInterests: lantern.profileInterests || [],
profileVibe: lantern.profileVibe || null,
}))
setSelectedVenue((prev) => {
if (!prev || prev.id !== venue.id) return prev
return { ...prev, activeLanterns: formattedLanterns }
})
} catch (err) {
devLog.error('Failed to refresh venue lanterns on foreground:', err)
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
}, [])- [ ] Step 2: Verify refs exist
Confirm that selectedVenueRef and currentUserRef already exist in Dashboard.jsx. They should โ Dashboard uses refs for async-safe access to state. If they don't exist, add them:
const selectedVenueRef = useRef(selectedVenue)
useEffect(() => { selectedVenueRef.current = selectedVenue }, [selectedVenue])
const currentUserRef = useRef(currentUser)
useEffect(() => { currentUserRef.current = currentUser }, [currentUser])- [ ] Step 3: Run linter
Run: npm run lint Expected: 0 errors (warnings are acceptable โ they are pre-existing)
- [ ] Step 4: Commit
git add apps/web/src/screens/dashboard/Dashboard.jsx
git commit -m "feat(dashboard): re-fetch venue lanterns on app foreground via visibilitychange"Task 7: Deprecate Cloud Function โ
Files:
Modify:
services/functions/firebase/modules/lanternCleanup.js[ ] Step 1: Add deprecation notice
At the top of the file, replace the existing JSDoc comment block (lines 5-16) with:
/**
* @deprecated This Cloud Function is superseded by the lanterns API endpoint
* POST /cleanup/expired (services/api/lanterns/src/routes/cleanup.js).
* Remove this function once the API endpoint is deployed and Cloud Scheduler
* is configured to call it.
*
* Scheduled cleanup of expired lanterns.
*
* Runs every 15 minutes to:
* 1. Find active lanterns whose expiresAt has passed
* 2. Mark them as extinguished with reason 'expired'
* 3. Reconcile activeLanternCount on affected venues
*
* This is a belt-and-suspenders approach alongside the Firestore TTL policy.
* The TTL policy auto-deletes documents, but this function ensures
* activeLanternCount stays accurate and status transitions are recorded.
*/- [ ] Step 2: Commit
git add services/functions/firebase/modules/lanternCleanup.js
git commit -m "chore: mark cleanupExpiredLanterns Cloud Function as deprecated"Task 8: Final Verification โ
- [ ] Step 1: Run linter
Run: npm run lint Expected: 0 errors
- [ ] Step 2: Run tests
Run: npm test -- --run Expected: No new failures (pre-existing failures are acceptable)
- [ ] Step 3: Verify all new files exist
Run: ls -la services/api/lanterns/src/services/cleanup.service.js services/api/lanterns/src/routes/cleanup.js Expected: Both files exist
- [ ] Step 4: Verify route registration
Run: grep -n 'cleanup' services/api/lanterns/src/index.js Expected: Import line and app.use('/cleanup', cleanupRoutes) line visible