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:
@@ -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);
|
||||
}
|
||||
@@ -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 * * *"
|
||||
|
||||
Reference in New Issue
Block a user