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",
|
"path": "/api/water-log/smartsheet-sync",
|
||||||
"schedule": "*/15 * * * *"
|
"schedule": "*/15 * * * *"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "/api/time-tracking/smartsheet-sync",
|
||||||
|
"schedule": "*/15 * * * *"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "/api/email-automation/abandoned-cart",
|
"path": "/api/email-automation/abandoned-cart",
|
||||||
"schedule": "0 */6 * * *"
|
"schedule": "0 */6 * * *"
|
||||||
|
|||||||
Reference in New Issue
Block a user