feat(time-tracking): add cron route for time-tracking smartsheet sync (cycle 8)

Mirrors the /api/water-log/smartsheet-sync route so time entries
eventually reach the customer's Smartsheet workbook even when the
worker loses connectivity between clock-out and the realtime push.

- New route: /api/time-tracking/smartsheet-sync — drains pending
  sync queue rows for every brand with sync_enabled = true.
- Per-frequency gating lives inside runScheduledTTSync:
  realtime = backstop only; every_15_min / hourly skip cleanly
  when not yet due (literal `skipped === true` branch).
- Auth: Bearer $SMARTSHEET_CRON_SECRET (falls back to $CRON_SECRET).
- Optional body { brandId? } for 'Retry now' admin button.
- Schedule: */15 * * * * in vercel.json (same cadence as water-log).

Co-authored-by: cyc8-bot <bot@routecomm>
This commit is contained in:
Nora
2026-07-03 19:26:18 -06:00
parent ad4d3c9976
commit da488b2cb0
2 changed files with 163 additions and 0 deletions
@@ -0,0 +1,159 @@
/**
* Cron-driven Time Tracking Smartsheet sync drain.
*
* Cycle 8 — mirrors `/api/water-log/smartsheet-sync/route.ts` so
* closed clock-outs eventually land in the customer's Smartsheet
* workbook even when the worker loses connectivity between clock-out
* and the next realtime push.
*
* Hit by Vercel cron (see vercel.json entry: every 15 minutes). The
* per-frequency gating happens inside `runScheduledTTSync`:
* - realtime: the event-driven push in `clockOutWorker` covers
* most cases; the cron recovers anything that failed
* to enqueue or was retried out.
* - every_15_min: drains the queue if last sync was ≥15m ago.
* - hourly: drains the queue if last sync was ≥60m ago.
*
* Auth:
* - Bearer token in `Authorization: Bearer $CRON_SECRET` header,
* or `?secret=$CRON_SECRET` query param (Vercel cron doesn't
* support either natively; the header is what curl / external
* schedulers use). Falls back to `CRON_SECRET` for cross-
* compatibility with the existing automation routes.
*
* Body (optional):
* - `{ brandId?: string }` — restrict to one brand (useful for the
* "Retry now" admin button). If omitted, runs for every active
* brand.
*/
import { NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { withPlatformAdmin } from "@/db/client";
import { timeTrackingSmartsheetConfig } from "@/db/schema/time-tracking";
import { runScheduledTTSync } from "@/services/time-tracking-smartsheet-sync";
const CRON_SECRET =
process.env.SMARTSHEET_CRON_SECRET ?? process.env.CRON_SECRET ?? "";
function unauthorized() {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
function authenticate(req: NextRequest): boolean {
if (!CRON_SECRET) {
// No secret configured: refuse in prod, allow in dev so local
// debugging doesn't need a token.
return process.env.NODE_ENV !== "production";
}
const headerToken = req.headers
.get("authorization")
?.replace(/^Bearer\s+/i, "");
const queryToken = new URL(req.url).searchParams.get("secret");
return headerToken === CRON_SECRET || queryToken === CRON_SECRET;
}
type BrandSummary = {
brandId: string;
considered: number;
synced: number;
skipped: number;
failed: number;
skippedReason?: string;
error?: string;
};
async function drainOneBrand(brandId: string): Promise<BrandSummary> {
try {
const result = await runScheduledTTSync(brandId);
// Discriminated union: TTDrainResult has `skipped: number`; the
// "skipped" branch has `skipped: true` (literal). The brand isn't
// due yet (hourly/15min gate) — return a zero summary so the
// totals stay clean.
if (result.skipped === true) {
return {
brandId,
considered: 0,
synced: 0,
skipped: 0,
failed: 0,
skippedReason: result.reason,
};
}
return {
brandId,
considered: result.considered,
synced: result.synced,
skipped: result.skipped,
failed: result.failed,
};
} catch (err) {
return {
brandId,
considered: 0,
synced: 0,
skipped: 0,
failed: 0,
error: (err as Error).message ?? "Unknown error",
};
}
}
// We need to enumerate every brand with a time-tracking smartsheet
// config enabled. The workspace read happens inside
// runScheduledTTSync, but we don't have a separate
// `timeTrackingSmartsheetConfig` helper that lists "active" brands.
// Instead, we ask Postgres directly via a raw query through
// `withPlatformAdmin`.
async function listActiveBrandIds(): Promise<string[]> {
return withPlatformAdmin(async (db) => {
const rows = await db
.select({ id: timeTrackingSmartsheetConfig.brandId })
.from(timeTrackingSmartsheetConfig)
.where(eq(timeTrackingSmartsheetConfig.syncEnabled, true));
return rows.map((r) => r.id);
});
}
export async function POST(req: NextRequest) {
if (!authenticate(req)) return unauthorized();
let body: { brandId?: string } = {};
try {
const text = await req.text();
if (text) body = JSON.parse(text);
} catch {
// ignore — body is optional
}
const start = Date.now();
const brandIds = body.brandId
? [body.brandId]
: await listActiveBrandIds();
const results = await Promise.all(
brandIds.map((brandId) => drainOneBrand(brandId)),
);
const totals = results.reduce(
(acc, r) => ({
considered: acc.considered + r.considered,
synced: acc.synced + r.synced,
skipped: acc.skipped + r.skipped,
failed: acc.failed + r.failed,
}),
{ considered: 0, synced: 0, skipped: 0, failed: 0 },
);
return NextResponse.json({
success: totals.failed === 0,
durationMs: Date.now() - start,
totals,
results,
});
}
// GET is also supported for easy browser testing during development.
// Same auth; in production it should be POST-only.
export async function GET(req: NextRequest) {
return POST(req);
}
+4
View File
@@ -25,6 +25,10 @@
"path": "/api/water-log/smartsheet-sync",
"schedule": "*/15 * * * *"
},
{
"path": "/api/time-tracking/smartsheet-sync",
"schedule": "*/15 * * * *"
},
{
"path": "/api/email-automation/abandoned-cart",
"schedule": "0 */6 * * *"