diff --git a/src/app/api/time-tracking/smartsheet-sync/route.ts b/src/app/api/time-tracking/smartsheet-sync/route.ts new file mode 100644 index 0000000..b6ad393 --- /dev/null +++ b/src/app/api/time-tracking/smartsheet-sync/route.ts @@ -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 { + 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 { + 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); +} \ No newline at end of file diff --git a/vercel.json b/vercel.json index c6b3f25..93b76a5 100644 --- a/vercel.json +++ b/vercel.json @@ -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 * * *"