diff --git a/docs/deployment.md b/docs/deployment.md index bf0c933..2d648b7 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -115,6 +115,64 @@ echo "$FIELD_ENCRYPTION_KEY" | bunx wrangler secret put FIELD_ENCRYPTION_KEY # …and GOOGLE_CLIENT_SECRET, BETTER_AUTH_URL ``` +### Custom worker entry + Cron Trigger (daily integration auto-sync) + +`wrangler.jsonc` does **not** point `main` at OpenNext's generated +`.open-next/worker.js`. It points at [`worker.ts`](../worker.ts) in the repo root, +which follows OpenNext's +[custom worker](https://opennext.js.org/cloudflare/howtos/custom-worker) pattern: + +- `fetch` is OpenNext's handler, forwarded unchanged. +- `scheduled` is new — Cloudflare calls it for the Cron Trigger + `"triggers": { "crons": ["0 22 * * *"] }` (cron is always UTC: 22:00 UTC = + **06:00 Asia/Taipei**). It runs the daily integration auto-sync + (see [integrations.md](integrations.md#每日自動同步)). +- The Durable Object classes OpenNext's worker exports (`DOQueueHandler`, + `DOShardedTagCache`, `BucketCachePurge`) are re-exported as-is. None are bound + today; they are there so turning on OpenNext caching later needs no change here. + +How `scheduled` runs app code: it does **not** import `src/lib/…` directly (that +code expects a Next request context — cookies/headers for next-intl, the session — +and wrangler would bundle it a second time). Instead it calls OpenNext's `fetch` +**as a function in the same isolate** with a synthetic +`POST /api/cron/integrations-autosync`. So the sync runs in +an ordinary Next route handler, and: + +- **Env / secrets**: OpenNext's `fetch` wrapper copies every string binding + (`vars` and `wrangler secret`s: `DATABASE_URL`, `FIELD_ENCRYPTION_KEY`, …) into + `process.env` on the isolate's first request — the synthetic request counts — + exactly as for web traffic. Nothing extra to configure. (The synthetic request + uses the real public origin because OpenNext also derives its origin from the + isolate's first request.) +- **Auth of the internal route**: `scheduled` mints a one-time 256-bit token, keeps + it in a `globalThis` set for the duration of the call and sends it in a header; + the route consumes it or answers 404. The token never leaves isolate memory, so + the route is unreachable from the internet and needs **no new secret** + ([`src/lib/cron-token.ts`](../src/lib/cron-token.ts)). `/api/cron` is excluded + from the auth proxy's matcher (`src/proxy.ts`), since there is no session. +- **Limits**: a cron invocation may run up to 15 minutes wall-clock on the paid + plan; the sync is sequential and I/O bound. A non-2xx from the route throws, so + Cloudflare marks that cron run as failed (Workers → the worker → Settings → + Trigger Events / logs). Every org × integration result is also logged + (`[autosync] …`) and stored in the integration's `config.lastAutoSync`. + +Nothing changes for deploys: `bun run cf:build` still produces `.open-next/`, and +`wrangler deploy` (Workers Builds' deploy command) bundles `worker.ts`, which +imports it. The cron schedule is part of `wrangler.jsonc`, so it is created/updated +by that same deploy. To exercise `scheduled` locally: +`bun run cf:build && bunx wrangler dev --test-scheduled`, then +`curl "http://localhost:8787/__scheduled?cron=0+22+*+*+*"` (wrangler's test +endpoint). **This really syncs** against whatever DB and integrations `.dev.vars` +points to — for a harmless check of the wiring, point it at a DB with no enabled +integrations. On production, the owner-facing way to test is 設定 › 整合 → +「立即執行自動同步」 (or MCP `run_integration_sync`), which runs the same code for +one organization. + +**Migration**: `migrations/0028_activity_channel_system.sql` allows +`activity_log.channel = 'system'` (the auto-sync's audit entries, no human actor). +Deploying before running it is safe — those log inserts are silently dropped by +the CHECK until it runs; the sync itself is unaffected. + ### Useful scripts | Script | What it does | diff --git a/docs/integrations.md b/docs/integrations.md index 090afa3..4c9a4e2 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -19,7 +19,8 @@ settings page just lists it alongside the others. | `src/lib/integrations/registry.ts` | Map of **implementations** (`testConnection`). Simpany and Wise are registered. Server only. | | `src/lib/integrations/store.ts` | The only code that reads/writes `org_integrations`. Server only. | | `src/app/dashboard/settings/integrations/` | Settings page, server actions (owner/admin only), connect Sheet. | -| `src/lib/mcp/tools-integrations.ts` | `list_integrations`, plus `requireIntegrationForTool` and `auditIntegrationCall` for provider tools. | +| `src/lib/mcp/tools-integrations.ts` | `list_integrations` (incl. `autoSync` / `lastAutoSync`), `run_integration_sync`, plus `requireIntegrationForTool` and `auditIntegrationCall` for provider tools. | +| `src/lib/integrations/autosync.ts` | Daily auto-sync (see [每日自動同步](#每日自動同步)). | ## Lifecycle @@ -341,6 +342,69 @@ Simpany 上已不存在的表單 / 員工會從本地刪掉,所以重跑是冪 未指定員工的薪資支出);MCP `simpany_list_salary_declarations`、`simpany_sync_salary_declarations`、 `salary_arrears`(見 [mcp.md](mcp.md))。 +## 每日自動同步 + +已連接、已開啟、狀態正常的整合每天自動同步一次,不用再有人去按「同步」。 + +**排程**:Cloudflare Cron Trigger `0 22 * * *`(UTC)= 每天**台北 06:00**。接線方式 +(`worker.ts` 的 `scheduled()` → 內部 route `/api/cron/integrations-autosync`)見 +[deployment.md](deployment.md#custom-worker-entry--cron-trigger-daily-integration-auto-sync)。 + +**範圍**:`org_integrations` 裡 `enabled = true AND status = 'connected'`、且 +`config.autoSync !== false` 的每一列(每個組織、每個整合)。 + +| 整合 | 跑什麼 | 等同於 | +| --- | --- | --- | +| Simpany | `syncSimpanyInvoices(org, 最近 90 天~今天)`(台北日期)→ `syncSalaryDeclarations(org, 今年)`;一月時另外跑去年 | 發票頁「從 Simpany 同步」+ 薪資頁「Simpany 薪資申報」同步 | +| Wise | `syncWiseTransactions(org, { dryRun: false })`;還沒設帳戶對應就略過(不算失敗、不打 Wise) | 帳戶頁「從 Wise 同步」按「寫入」 | + +**安全**: + +- **只同步,不開立**:Simpany 只走列表 / 明細 / 薪資申報的 GET;自動同步沒有任何開立、 + 作廢發票或其他 Simpany 寫入端點的程式碼路徑。Wise 本來就只有 GET(見上方唯讀保證)。 +- Wise 寫入是安全的:以 referenceNumber 去重(`ON CONFLICT DO NOTHING`,刪掉的也不會長回來)、 + 永遠不早於切換日、新列一律「待確認」。Simpany 發票與薪資申報同步都是冪等 upsert。 +- **依序、不平行**:一個組織一個整合接著跑,對 Simpany 的非官方 API 溫和一點。 +- **互相隔離**:每個 組織 × 整合(以及 Simpany 的發票 / 薪資各步驟)各自 try/catch, + 一個失敗不影響其他。401 / 5xx 照舊由 provider 程式碼 `markNeedsReauth` / + `recordSyncFailure`;本地錯誤(寫 DB…)由自動同步補記 `recordSyncFailure`。 + 整合在跑的途中被關掉或轉成需要重新連接時不覆寫 `last_error`。 +- 發票同步一次最多抓 80 張明細(`MAX_DETAIL_FETCHES`,Workers subrequest 上限); + 沒抓完會在摘要註明,隔天接著做。 + +**結果**: + +- `config.lastAutoSync = { at, ok, summary, error, trigger }`(`updateConfig` 淺層合併;非機密, + 成員與 MCP 看得到)。`summary` 是 zh-TW 的一行摘要,例如 + 「發票 2026-07-01~2026-09-29:讀到 12 張、新增 1、更新 0、作廢 0、自動綁定 1、待確認 0;薪資申報 2026:寫入 36 筆」。 +- 操作紀錄每個 組織 × 整合 一筆,entity = `integration`。排程觸發的來源是 **system**(`channel = 'system'`, + 操作人欄位全 NULL —— 不冒充任何成員;需要 `migrations/0028`);手動觸發記成按下的那位成員(web / mcp)。 + +**開關**(owner / admin):設定 › 整合 每個已連接的 Simpany / Wise 列上有「自動同步」開關, +存成 `config.autoSync`(沒有這個欄位 = 開,預設開);下面一行顯示 +「上次自動同步:YYYY-MM-DD HH:mm · 成功 / 失敗:…」(台北時間)。 +關掉自動同步不影響手動同步與 MCP 工具。 + +**手動觸發**(測試用,只跑目前組織、同一條程式碼路徑 `runScheduledSync(now, { orgId, trigger: "manual" })`): + +- Web:設定 › 整合 上方的「立即執行自動同步」(owner / admin)。 +- MCP:`run_integration_sync`(owner / admin,write,openWorldHint)。 + +| Where | What | +| --- | --- | +| `src/lib/integrations/autosync.ts` | `runScheduledSync(now, { orgId?, trigger?, audit? })`、`autoSyncWindow(now)` | +| `src/lib/integrations/autosync-config.ts` | client-safe:`isAutoSyncOn(config)`、`parseLastAutoSync(config)`、`AUTO_SYNC_PROVIDERS` | +| `src/app/api/cron/integrations-autosync/route.ts` | cron 的內部進入點(一次性 token,外部一律 404) | +| `src/lib/cron-token.ts` | 一次性 token(`worker.ts` 與 route 共用 `globalThis` 上的 Set) | +| `src/i18n/server-t.ts` | `runAsSystem()` / `getServerT(namespace)`:沒有 request 語系時用 zh-TW 字典 | +| `worker.ts`、`wrangler.jsonc` `triggers` | Cron Trigger 與自訂 worker 進入點 | + +**i18n**:同步路徑上唯一用到翻譯的是 `store.ts`(`requireEnabledIntegration` 的錯誤訊息、 +`integrationDisplayName`)。它們改用 `getServerT()`:在 `runAsSystem()` 裡(自動同步一律如此) +回傳 zh-TW 的 `createTranslator`,不碰 `cookies()` / `headers()`;其他情況就是原本的 +`getTranslations`,web 與 MCP 行為不變。新增會在自動同步路徑上用到翻譯的程式碼時, +用 `getServerT` 而不是 `getTranslations`。 + ## Security rules - Credentials are encrypted with `FIELD_ENCRYPTION_KEY` (see diff --git a/docs/mcp.md b/docs/mcp.md index 9e3c9d4..66895fa 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -123,8 +123,8 @@ the `tools-*.ts` modules): - `_meta["openai/toolInvocation/invoking" | "invoked"]`, the status line ChatGPT shows while a call is in flight. -**Output schemas.** Every tool declares an `outputSchema` — all 88 of them, as of -server version 1.7.0 (the Simpany tools whose result shape comes from Simpany's +**Output schemas.** Every tool declares an `outputSchema` — all 89 of them, as of +server version 1.8.0 (the Simpany tools whose result shape comes from Simpany's unofficial API declare an open object schema). When a tool declares one the handler additionally returns the result as MCP `structuredContent` (the JSON text block stays, per MCP's back-compat recommendation), which is what ChatGPT and Codex prefer over parsing @@ -245,7 +245,12 @@ reconciliations: `list_reconciliations` + **Integrations** — `list_integrations` shows, per external integration (Simpany e-invoice, Wise), whether it is available on this server, connected, switched on, and healthy (`status`, `lastError`, `lastSyncedAt`, -`tokenExpiresAt`) plus its non-secret `config`. It never returns credentials. +`tokenExpiresAt`) plus its non-secret `config`, the daily auto-sync switch +(`autoSync`) and the last auto-sync result (`lastAutoSync`). It never returns +credentials. `run_integration_sync` (owner/admin, write, open world) runs the daily +auto-sync right now for the current organization only — the same code path as the +06:00 Taipei Cron Trigger (Simpany invoices + salary declarations, Wise +transactions; never issues or voids invoices). Integration business tools live in their own `tools-.ts` and stay in `tools/list` whether or not the org has connected the integration; at call time they go through `requireIntegrationForTool()` and fail with a clear zh-TW message diff --git a/eslint.config.mjs b/eslint.config.mjs index 9d6f305..96ba8f7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,6 +15,11 @@ const eslintConfig = defineConfig([ // Generated by `bun run cf:typegen` (wrangler). Regenerated wholesale, so // hand-fixing lint findings in it would just be undone on the next run. "cloudflare-env.d.ts", + // OpenNext / wrangler build output (`bun run cf:build`). worker.ts at the repo + // root imports .open-next/worker.js, and without this eslint would crawl the + // whole generated server bundle. + ".open-next/**", + ".wrangler/**", ]), ]); diff --git a/migrations/0028_activity_channel_system.sql b/migrations/0028_activity_channel_system.sql new file mode 100644 index 0000000..f5e921d --- /dev/null +++ b/migrations/0028_activity_channel_system.sql @@ -0,0 +1,14 @@ +-- 0028: 操作紀錄加一個來源 'system'。 +-- +-- 整合每日自動同步(src/lib/integrations/autosync.ts,Cloudflare Cron Trigger 觸發) +-- 不是任何成員發起的,所以不能記成 web 或 mcp、也不能冒充某位成員。它寫進 +-- activity_log 時 channel = 'system',actor_user_id / actor_email / actor_name 皆 NULL。 +-- +-- 在這支 migration 跑之前部署程式碼也安全:src/db/activity.ts 的 record() 會吞掉 +-- CHECK 違規,只是少記那幾筆「系統自動同步」的操作紀錄,同步本身照常。 +-- +-- Forward-only,只放寬 CHECK。Run AFTER 0027。 + +ALTER TABLE activity_log DROP CONSTRAINT chk_activity_channel; +ALTER TABLE activity_log + ADD CONSTRAINT chk_activity_channel CHECK (channel = ANY (ARRAY['web', 'mcp', 'system'])); diff --git a/src/app/api/cron/integrations-autosync/route.ts b/src/app/api/cron/integrations-autosync/route.ts new file mode 100644 index 0000000..26bdae5 --- /dev/null +++ b/src/app/api/cron/integrations-autosync/route.ts @@ -0,0 +1,21 @@ +import { consumeCronToken, CRON_TOKEN_HEADER } from "@/lib/cron-token"; +import { runScheduledSync } from "@/lib/integrations/autosync"; + +export const dynamic = "force-dynamic"; + +/** + * 整合每日自動同步的進入點 —— **只給 worker.ts 的 `scheduled()` 呼叫**。 + * + * `scheduled()` 在同一個 isolate 裡以函式呼叫 OpenNext 的 fetch handler 打這條路徑, + * 帶一個只存在於記憶體、用完即刪的 token(src/lib/cron-token.ts)。從網際網路打進來的 + * 請求不可能有那個 token,一律 404(不回 401 / 403,不透露這裡有東西)。 + * + * src/proxy.ts 的 matcher 排除了 /api/cron:這裡沒有 session cookie,不能被導去登入頁。 + */ +export async function POST(request: Request): Promise { + if (!consumeCronToken(request.headers.get(CRON_TOKEN_HEADER))) { + return new Response("Not Found", { status: 404 }); + } + const result = await runScheduledSync(new Date(), { trigger: "cron" }); + return Response.json(result); +} diff --git a/src/app/dashboard/activity/page.tsx b/src/app/dashboard/activity/page.tsx index c818af8..6725724 100644 --- a/src/app/dashboard/activity/page.tsx +++ b/src/app/dashboard/activity/page.tsx @@ -80,11 +80,11 @@ export default async function ActivityPage() { {formatDateTime(r.createdAt)} - {r.actorName ?? r.actorEmail ?? "—"} + {r.actorName ?? r.actorEmail ?? (r.channel === "system" ? t("systemActor") : "—")} - {r.channel === "mcp" ? ( - {t("source.mcp")} + {r.channel === "mcp" || r.channel === "system" ? ( + {t(`source.${r.channel}`)} ) : ( {t("source.web")} )} diff --git a/src/app/dashboard/settings/integrations/actions.ts b/src/app/dashboard/settings/integrations/actions.ts index ebcdb7b..51fd2cd 100644 --- a/src/app/dashboard/settings/integrations/actions.ts +++ b/src/app/dashboard/settings/integrations/actions.ts @@ -12,7 +12,10 @@ import { getIntegration, saveConnection, setIntegrationEnabled, + updateConfig, } from "@/lib/integrations/store"; +import { supportsAutoSync } from "@/lib/integrations/autosync-config"; +import { runScheduledSync } from "@/lib/integrations/autosync"; import { isIntegrationProviderId, type IntegrationCatalogEntry, @@ -246,3 +249,81 @@ export async function disconnectIntegration( return { ok: false, error: e instanceof Error ? e.message : t("toast.failed") }; } } + +/** 每日自動同步的開關(config.autoSync)。沒存 = 開;關掉存 false。 */ +export async function setIntegrationAutoSyncAction( + providerArg: string, + autoSync: boolean, +): Promise { + const t = await getTranslations("integrations"); + // 在 try 外面:未登入時 requireOrg() 會 redirect,那個例外不能被吞掉。 + const me = await requireManager(); + if ("error" in me) return { ok: false, error: me.error }; + try { + if (!isIntegrationProviderId(providerArg) || !supportsAutoSync(providerArg)) { + return { ok: false, error: t("errors.unknownProvider", { provider: String(providerArg) }) }; + } + const provider: IntegrationProviderId = providerArg; + const name = t(`providers.${provider}.name`); + const row = await getIntegration(me.orgId, provider); + if (!row) return { ok: false, error: t("errors.notConnected", { name }) }; + if ((row.config.autoSync !== false) !== autoSync) { + await updateConfig(me.orgId, provider, { autoSync }); + await logWeb( + me.orgId, + "update", + "integration", + null, + autoSync ? t("activity.autoSyncOn", { name }) : t("activity.autoSyncOff", { name }), + ); + } + revalidatePath(PAGE); + return { ok: true }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : t("toast.failed") }; + } +} + +export type RunAutoSyncState = IntegrationActionState & { + /** 實際跑了幾個 / 失敗幾個(跑了 0 個 = 沒有符合條件的整合)。 */ + ran?: number; + failed?: number; +}; + +/** + * 「立即執行自動同步」:只跑目前組織,走跟每日 cron 完全相同的 runScheduledSync。 + * 給 owner / admin 測試用;操作紀錄記成按下按鈕的這位成員(channel = web),不是 system。 + */ +export async function runAutoSyncNowAction(): Promise { + const t = await getTranslations("integrations"); + // 在 try 外面:未登入時 requireOrg() 會 redirect,那個例外不能被吞掉。 + const me = await requireManager(); + if ("error" in me) return { ok: false, error: me.error }; + try { + const run = await runScheduledSync(new Date(), { + orgId: me.orgId, + trigger: "manual", + audit: async (orgId, provider, ok, summary) => { + await logWeb( + orgId, + "update", + "integration", + null, + t("activity.autoSyncRun", { + name: t(`providers.${provider}.name`), + result: ok ? t("activity.autoSyncOk") : t("activity.autoSyncFailed"), + summary, + }), + ); + }, + }); + revalidatePath(PAGE); + return { + ok: true, + ran: run.results.length, + failed: run.results.filter((r) => !r.ok).length, + }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : t("toast.failed") }; + } +} diff --git a/src/app/dashboard/settings/integrations/integrations-client.tsx b/src/app/dashboard/settings/integrations/integrations-client.tsx index 963c5a5..14b6aa1 100644 --- a/src/app/dashboard/settings/integrations/integrations-client.tsx +++ b/src/app/dashboard/settings/integrations/integrations-client.tsx @@ -4,7 +4,7 @@ import { useOptimistic, useState, useTransition } from "react"; import Link from "next/link"; import { toast } from "sonner"; import { useTranslations } from "next-intl"; -import { Info, Link2, RefreshCw, Unlink } from "lucide-react"; +import { CalendarClock, Info, Link2, Play, RefreshCw, Unlink } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; @@ -38,6 +38,8 @@ import { connectIntegration, disconnectIntegration, reconnectIntegration, + runAutoSyncNowAction, + setIntegrationAutoSyncAction, setIntegrationEnabledAction, } from "./actions"; @@ -57,6 +59,12 @@ export type IntegrationRowData = { connectedByName: string | null; lastSyncedAt: string | null; lastError: string | null; + /** 每日自動同步;這個整合沒有自動同步可跑時為 null。 */ + autoSync: { + on: boolean; + /** 上次自動同步;`at` 已格式化成台北時間。 */ + last: { at: string; ok: boolean; error: string | null; manual: boolean } | null; + } | null; } | null; }; @@ -65,10 +73,13 @@ type SheetTarget = { row: IntegrationRowData; mode: "connect" | "reconnect"; non export function IntegrationsList({ rows, canManage, + canRunAutoSync, calendar, }: Readonly<{ rows: IntegrationRowData[]; canManage: boolean; + /** owner / admin 且至少有一個整合支援自動同步時,顯示「立即執行自動同步」。 */ + canRunAutoSync: boolean; calendar: { connected: boolean; ownerLabel: string | null }; }>) { const t = useTranslations("integrations"); @@ -89,6 +100,7 @@ export function IntegrationsList({ {t("readOnlyNote")}

)} +
    {rows.map((row) => ( ) { + const t = useTranslations("integrations"); + const [pending, start] = useTransition(); + + function runNow() { + start(async () => { + const res = await runAutoSyncNowAction(); + if (!res.ok) { + toast.error(res.error ?? t("toast.failed")); + return; + } + if (!res.ran) { + toast.info(t("autoSync.runNothing")); + return; + } + const msg = t("autoSync.runDone", { ok: res.ran - (res.failed ?? 0), failed: res.failed ?? 0 }); + if (res.failed) toast.error(msg); + else toast.success(msg); + }); + } + + return ( +
    +

    + + {t("autoSync.schedule")} +

    + {canRun ? ( + + ) : null} +
    + ); +} + function LogoTile({ letter, className }: Readonly<{ letter: string; className: string }>) { return (
    {t(`providers.${row.id}.description`)}

    )} + {c?.autoSync ? : null}
    + {c?.autoSync ? ( + + ) : null} ["autoSync"]>["last"] }>) { + const t = useTranslations("integrations"); + if (!last) return

    {t("autoSync.never")}

    ; + const result = last.ok + ? t("autoSync.ok") + : t("autoSync.failed", { error: last.error ?? t("status.unknownError") }); + return ( +

    + {t("autoSync.last", { date: last.at, result })} + {last.manual ? ` ${t("autoSync.lastManual")}` : ""} +

    + ); +} + +/** 列上的「自動同步」小開關(config.autoSync;預設開)。 */ +function AutoSyncSwitch({ + provider, + name, + on, + disabled, +}: Readonly<{ provider: IntegrationProviderId; name: string; on: boolean; disabled: boolean }>) { + const t = useTranslations("integrations"); + const [pending, start] = useTransition(); + const [optimisticOn, setOptimisticOn] = useOptimistic(on); + const id = `autosync-${provider}`; + + function toggle(next: boolean) { + start(async () => { + setOptimisticOn(next); + const res = await setIntegrationAutoSyncAction(provider, next); + if (!res.ok) toast.error(res.error ?? t("toast.failed")); + }); + } + + return ( +
    + + +
    + ); +} + /** * Google 日曆不走 org_integrations(token 在 better-auth 的 account 表、設定在 * calendar_settings),這裡只反映狀態,管理介面在同頁下方的 #google-calendar。 diff --git a/src/app/dashboard/settings/integrations/page.tsx b/src/app/dashboard/settings/integrations/page.tsx index fdf12b0..319842e 100644 --- a/src/app/dashboard/settings/integrations/page.tsx +++ b/src/app/dashboard/settings/integrations/page.tsx @@ -10,6 +10,12 @@ import { formatDate, formatDateTime } from "@/lib/format"; import { INTEGRATION_CATALOG, INTEGRATION_ORDER } from "@/lib/integrations/catalog"; import { getProvider } from "@/lib/integrations/registry"; import { listIntegrations } from "@/lib/integrations/store"; +import type { IntegrationConfig, IntegrationProviderId } from "@/lib/integrations/types"; +import { + isAutoSyncOn, + parseLastAutoSync, + supportsAutoSync, +} from "@/lib/integrations/autosync-config"; import { IntegrationsList, type IntegrationRowData } from "./integrations-client"; import { CalendarSettingsClient } from "./calendar-settings-client"; import { WiseMappingSection } from "./wise-mapping-client"; @@ -17,6 +23,43 @@ import { loadWiseMappingView } from "./wise-mapping-data"; export const dynamic = "force-dynamic"; +/** 自動同步時間一律顯示台北時間(Worker 的時區是 UTC):YYYY-MM-DD HH:mm。 */ +function formatTaipeiMinute(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: "Asia/Taipei", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).formatToParts(d); + const get = (type: string) => parts.find((p) => p.type === type)?.value ?? ""; + return `${get("year")}-${get("month")}-${get("day")} ${get("hour")}:${get("minute")}`; +} + +/** 列上「自動同步」開關與上次結果;不支援自動同步的整合回 null。 */ +function autoSyncView( + id: IntegrationProviderId, + config: IntegrationConfig, +): NonNullable["autoSync"] { + if (!supportsAutoSync(id)) return null; + const last = parseLastAutoSync(config); + return { + on: isAutoSyncOn(config), + last: last + ? { + at: formatTaipeiMinute(last.at), + ok: last.ok, + error: last.error, + manual: last.trigger === "manual", + } + : null, + }; +} + export default async function IntegrationsPage() { const t = await getTranslations("integrations"); const { orgId, userId, role } = await requireOrgWithRole(); @@ -46,6 +89,7 @@ export default async function IntegrationsPage() { connectedByName: s.connectedByName, lastSyncedAt: s.lastSyncedAt ? formatDateTime(s.lastSyncedAt) : null, lastError: s.lastError, + autoSync: autoSyncView(id, s.config), } : null, }; @@ -60,6 +104,7 @@ export default async function IntegrationsPage() { r.connection?.autoSync)} canManage={canManage} calendar={{ connected: calendarConnected, ownerLabel: calendarOwner }} /> diff --git a/src/db/activity.ts b/src/db/activity.ts index 1e6dd2f..b8e4b19 100644 --- a/src/db/activity.ts +++ b/src/db/activity.ts @@ -6,9 +6,17 @@ import { getSession } from "@/lib/session"; export type ActivityAction = "create" | "update" | "delete" | "read"; +/** + * web = 登入的成員在網頁操作;mcp = 透過 OAuth MCP;system = 沒有人觸發的排程工作 + * (例如整合每日自動同步)。system 沒有操作人 —— actor 欄位一律 NULL,不冒充任何成員。 + * 'system' 需要 migrations/0028 放寬 chk_activity_channel;還沒跑之前寫入會被 CHECK 擋下, + * 而 record() 會吞掉錯誤,所以只是少一筆紀錄,不會讓同步失敗。 + */ +export type ActivityChannel = "web" | "mcp" | "system"; + type RecordArgs = { orgId: string; - channel: "web" | "mcp"; + channel: ActivityChannel; actorUserId: string | null; actorEmail: string | null; actorName: string | null; @@ -103,3 +111,26 @@ export async function logMcp( // ignore } } + +// Scheduled / background work with no human actor (e.g. the daily integration +// auto-sync). Deliberately NOT attributed to any member: actor fields stay NULL +// and channel = 'system'. +export async function logSystem( + orgId: string, + action: ActivityAction, + entityType: string, + entityId: number | null, + summary?: string, +) { + await record({ + orgId, + channel: "system", + actorUserId: null, + actorEmail: null, + actorName: null, + action, + entityType, + entityId, + summary, + }); +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 359fb9c..0304672 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -645,7 +645,7 @@ export const activityLog = pgTable("activity_log", { createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), }, (table) => [ index("idx_activity_org_time").using("btree", table.organizationId.asc().nullsLast(), table.createdAt.desc()), - check("chk_activity_channel", sql`channel = ANY (ARRAY['web'::text, 'mcp'::text])`), + check("chk_activity_channel", sql`channel = ANY (ARRAY['web'::text, 'mcp'::text, 'system'::text])`), check("chk_activity_action", sql`action = ANY (ARRAY['create'::text, 'update'::text, 'delete'::text, 'read'::text])`), ]); diff --git a/src/i18n/messages/activity.ts b/src/i18n/messages/activity.ts index 7ffa470..0922e1e 100644 --- a/src/i18n/messages/activity.ts +++ b/src/i18n/messages/activity.ts @@ -15,7 +15,9 @@ const activity = { source: { mcp: { "zh-TW": "MCP", en: "MCP" }, web: { "zh-TW": "網頁", en: "Web" }, + system: { "zh-TW": "系統排程", en: "Scheduled" }, }, + systemActor: { "zh-TW": "系統", en: "System" }, action: { create: { "zh-TW": "新增", en: "Added" }, update: { "zh-TW": "修改", en: "Updated" }, diff --git a/src/i18n/messages/integrations.ts b/src/i18n/messages/integrations.ts index 0809514..9e7812b 100644 --- a/src/i18n/messages/integrations.ts +++ b/src/i18n/messages/integrations.ts @@ -65,6 +65,32 @@ const integrations = { toggleLabel: { "zh-TW": "開啟 {name}", en: "Enable {name}" }, }, notImplemented: { "zh-TW": "尚未開放連接", en: "Not available yet" }, + autoSync: { + label: { "zh-TW": "自動同步", en: "Auto-sync" }, + toggleLabel: { "zh-TW": "{name} 每日自動同步", en: "Daily auto-sync for {name}" }, + schedule: { + "zh-TW": "已開啟、狀態正常且沒關掉「自動同步」的整合,每天台北時間 06:00 會自動同步一次(Simpany:最近 90 天發票 + 今年薪資申報;Wise:交易,新列標為待確認)。", + en: "Every day at 06:00 Taipei time, each integration that is switched on, healthy and has auto-sync on is synced once (Simpany: last 90 days of invoices + this year's salary declarations; Wise: transactions, new rows flagged for review).", + }, + last: { + "zh-TW": "上次自動同步:{date} · {result}", + en: "Last auto-sync: {date} · {result}", + }, + lastManual: { "zh-TW": "(手動執行)", en: "(run manually)" }, + ok: { "zh-TW": "成功", en: "Succeeded" }, + failed: { "zh-TW": "失敗:{error}", en: "Failed: {error}" }, + never: { "zh-TW": "尚未自動同步過", en: "Not auto-synced yet" }, + runNow: { "zh-TW": "立即執行自動同步", en: "Run auto-sync now" }, + running: { "zh-TW": "同步中…", en: "Syncing…" }, + runDone: { + "zh-TW": "自動同步完成:{ok} 個成功、{failed} 個失敗", + en: "Auto-sync finished: {ok} succeeded, {failed} failed", + }, + runNothing: { + "zh-TW": "沒有可自動同步的整合(需要已開啟、狀態正常且自動同步開著)", + en: "Nothing to sync (an integration must be switched on, healthy and have auto-sync on)", + }, + }, sheet: { connectTitle: { "zh-TW": "連接 {name}", en: "Connect {name}" }, reconnectTitle: { "zh-TW": "重新連接 {name}", en: "Reconnect {name}" }, @@ -147,6 +173,14 @@ const integrations = { enabled: { "zh-TW": "開啟 {name}", en: "{name} switched on" }, disabled: { "zh-TW": "關閉 {name}", en: "{name} switched off" }, disconnected: { "zh-TW": "中斷 {name}", en: "{name} disconnected" }, + autoSyncOn: { "zh-TW": "開啟 {name} 自動同步", en: "{name} auto-sync switched on" }, + autoSyncOff: { "zh-TW": "關閉 {name} 自動同步", en: "{name} auto-sync switched off" }, + autoSyncRun: { + "zh-TW": "手動執行 {name} 自動同步({result}):{summary}", + en: "Ran {name} auto-sync manually ({result}): {summary}", + }, + autoSyncOk: { "zh-TW": "成功", en: "succeeded" }, + autoSyncFailed: { "zh-TW": "失敗", en: "failed" }, }, } satisfies Dictionary; diff --git a/src/i18n/server-t.ts b/src/i18n/server-t.ts new file mode 100644 index 0000000..cafd70c --- /dev/null +++ b/src/i18n/server-t.ts @@ -0,0 +1,48 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { createTranslator, type NamespaceKeys, type NestedKeyOf } from "next-intl"; +import { getTranslations } from "next-intl/server"; +import { defaultLocale } from "./config"; +import { messages, type Messages } from "./messages"; + +/** + * 沒有「使用者請求」時也能用的翻譯函式。 + * + * next-intl 的 `getTranslations` 靠 src/i18n/request.ts 從 cookie / Accept-Language + * 決定語系,而 `cookies()` / `headers()` 只在 Next 的 request scope 裡存在。排程工作 + * (整合每日自動同步,src/lib/integrations/autosync.ts)不是任何人發出的請求, + * 所以走的是另一條路:在 `runAsSystem()` 裡執行的程式碼,`getServerT()` 一律回傳 + * 預設語系(zh-TW,也是 source of truth)的 translator,直接吃打包進來的字典。 + * + * 不在 system context 裡時就是原封不動的 `getTranslations` —— web 與 MCP 的行為不變。 + * + * 刻意不用「try getTranslations、失敗就退回」:Next 在靜態預算時會用丟例外的方式 + * 標記「這頁用到了 cookies()」,吞掉那個例外會讓頁面被錯誤地當成靜態。用顯式的 + * context 旗標判斷,就不會碰到 Next 的控制流程。 + */ + +const systemContext = new AsyncLocalStorage(); + +/** 以「系統」身分執行(沒有使用者、沒有 request 語系)。訊息一律 zh-TW。 */ +export function runAsSystem(fn: () => Promise): Promise { + return systemContext.run(true, fn); +} + +/** 目前是否在 runAsSystem 裡。 */ +export function inSystemContext(): boolean { + return systemContext.getStore() === true; +} + +type Namespace = NamespaceKeys>; +type ServerT = ReturnType>; + +/** 同 `getTranslations(namespace)`;在 runAsSystem 裡改用 zh-TW 字典,不需要 request。 */ +export async function getServerT(namespace: N): Promise> { + if (inSystemContext()) { + return createTranslator({ + locale: defaultLocale, + messages: messages[defaultLocale], + namespace, + }); + } + return getTranslations(namespace); +} diff --git a/src/lib/cron-token.ts b/src/lib/cron-token.ts new file mode 100644 index 0000000..5f8da78 --- /dev/null +++ b/src/lib/cron-token.ts @@ -0,0 +1,53 @@ +/** + * Cron → Next route 的一次性通行證。 + * + * Cloudflare Cron Trigger 叫的是 worker.ts 的 `scheduled()`,那裡沒有 Next 的 request + * context。所以 `scheduled()` 不自己跑同步,而是用 OpenNext 的 `fetch` handler 在 + * **同一個 isolate 裡直接呼叫** `/api/cron/integrations-autosync`(函式呼叫,不經過網路), + * 讓同步跑在一般的 Next route handler 裡 —— env、DB、i18n 都跟網頁請求一模一樣。 + * + * 那條 route 在網際網路上也摸得到,所以要一個外人拿不到的憑證:`scheduled()` 每次產生 + * 一個 256-bit 隨機 token,放進 globalThis 上的集合,帶在 header 裡呼叫;route 檢查 + * token 在集合裡、用完即刪。token 只存在這個 isolate 的記憶體、只活在那一次呼叫期間, + * 不需要另外設定任何 secret。 + * + * 這支刻意**沒有任何 import**:worker.ts(wrangler 打包)與 route(Next 打包)各有一份 + * 模組副本,兩邊靠同一個 `Symbol.for` key 共用 globalThis 上的同一個 Set。 + */ + +export const CRON_TOKEN_HEADER = "x-pathors-cron-token"; + +/** route 的路徑。worker.ts 與 src/proxy.ts 的 matcher 例外都要對得上。 */ +export const AUTOSYNC_CRON_PATH = "/api/cron/integrations-autosync"; + +const KEY = Symbol.for("pathors.cronTokens"); + +function tokens(): Set { + const g = globalThis as unknown as Record | undefined>; + let set = g[KEY]; + if (!set) { + set = new Set(); + g[KEY] = set; + } + return set; +} + +/** 產生並登記一個一次性 token(給 scheduled() 用)。 */ +export function issueCronToken(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + const token = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + tokens().add(token); + return token; +} + +/** 撤銷(scheduled() 在 finally 裡呼叫,避免 route 沒走到時殘留)。 */ +export function revokeCronToken(token: string): void { + tokens().delete(token); +} + +/** route 用:token 有效就消耗掉並回 true。 */ +export function consumeCronToken(token: string | null): boolean { + if (token?.length !== 64) return false; + return tokens().delete(token); +} diff --git a/src/lib/integrations/autosync-config.ts b/src/lib/integrations/autosync-config.ts new file mode 100644 index 0000000..c108b72 --- /dev/null +++ b/src/lib/integrations/autosync-config.ts @@ -0,0 +1,47 @@ +import type { IntegrationConfig, IntegrationProviderId } from "./types"; + +// 每日自動同步在 org_integrations.config 裡的兩個非機密欄位。client 與 server 都會 +// import 這支(設定頁、MCP、autosync.ts),所以只能有純函式與型別,不能碰 DB。 +// +// config.autoSync false = 關閉自動同步;沒有這個欄位或 true = 開啟(預設開)。 +// config.lastAutoSync 最近一次自動同步的結果(見 LastAutoSync),由 autosync.ts 寫入。 + +/** 有自動同步可跑的整合(其他整合的列不顯示開關)。 */ +export const AUTO_SYNC_PROVIDERS: readonly IntegrationProviderId[] = ["simpany", "wise"]; + +export function supportsAutoSync(provider: IntegrationProviderId): boolean { + return AUTO_SYNC_PROVIDERS.includes(provider); +} + +/** 預設開啟:只有明確存了 false 才算關。 */ +export function isAutoSyncOn(config: IntegrationConfig | null | undefined): boolean { + return config?.autoSync !== false; +} + +export type AutoSyncTrigger = "cron" | "manual"; + +/** config.lastAutoSync 的形狀。summary / error 是給人看的 zh-TW 文字,不含憑證。 */ +export type LastAutoSync = { + /** ISO 8601。 */ + at: string; + ok: boolean; + summary: string; + error: string | null; + /** cron = Cloudflare 排程;manual = owner / admin 按「立即執行自動同步」或 MCP run_integration_sync。 */ + trigger: AutoSyncTrigger; +}; + +/** 防禦式讀 config.lastAutoSync(jsonb,形狀不保證);認不得就回 null。 */ +export function parseLastAutoSync(config: IntegrationConfig | null | undefined): LastAutoSync | null { + const raw = config?.lastAutoSync; + if (!raw || typeof raw !== "object") return null; + const r = raw as Record; + if (typeof r.at !== "string" || typeof r.ok !== "boolean") return null; + return { + at: r.at, + ok: r.ok, + summary: typeof r.summary === "string" ? r.summary : "", + error: typeof r.error === "string" ? r.error : null, + trigger: r.trigger === "manual" ? "manual" : "cron", + }; +} diff --git a/src/lib/integrations/autosync.ts b/src/lib/integrations/autosync.ts new file mode 100644 index 0000000..1aba508 --- /dev/null +++ b/src/lib/integrations/autosync.ts @@ -0,0 +1,295 @@ +import { addDays, format, parseISO } from "date-fns"; +import { and, asc, eq } from "drizzle-orm"; +import { getDb } from "@/db"; +import { orgIntegrations } from "@/db/schema"; +import { logSystem } from "@/db/activity"; +import { runAsSystem } from "@/i18n/server-t"; +import { syncSalaryDeclarations } from "@/lib/simpany-salary"; +import { syncSimpanyInvoices, taipeiDate } from "@/lib/simpany-sync"; +import { parseWiseConfig, syncWiseTransactions } from "@/lib/wise-sync"; +import { + isAutoSyncOn, + supportsAutoSync, + type AutoSyncTrigger, + type LastAutoSync, +} from "./autosync-config"; +import { IntegrationUnavailableError, recordSyncFailure, updateConfig } from "./store"; +import type { IntegrationConfig, IntegrationProviderId } from "./types"; + +/** + * 整合每日自動同步。 + * + * 觸發:Cloudflare Cron Trigger(wrangler.jsonc `triggers.crons`,每天 22:00 UTC = 台北 06:00) + * → worker.ts 的 `scheduled()` → 以一次性 token 呼叫本 Worker 的 + * `/api/cron/integrations-autosync` → `runScheduledSync()`。owner / admin 也可以在 + * 設定 › 整合 按「立即執行自動同步」、或用 MCP `run_integration_sync` 只跑自己的組織 —— + * 三者走同一條程式碼路徑。 + * + * 範圍:`enabled = true AND status = 'connected' AND config.autoSync !== false` 的每一列。 + * - simpany → 發票同步(最近 90 天,台北日期)+ 薪資申報同步(今年;一月時連去年也跑) + * - wise → 交易同步(dryRun: false)。去重靠 referenceNumber、永遠不早於切換日、 + * 新列一律標「待確認」,所以每天跑是安全的。 + * + * 安全:這裡只呼叫「同步」—— 對 Simpany 只有讀(列表 / 明細 / 薪資申報 GET), + * 永遠不開立、不作廢發票,也不碰任何 Simpany 寫入端點;對 Wise 只有 GET。 + * + * 逐一、依序執行(不平行):Simpany 是非官方 API,不要一次打太多。每個 組織 × 整合 各自 + * try/catch,一個失敗不影響其他;結果寫進 config.lastAutoSync,並在操作紀錄記一筆。 + */ + +/** Simpany 發票同步的回溯天數(與網頁「從 Simpany 同步」的預設一致)。 */ +const INVOICE_LOOKBACK_DAYS = 90; + +export type AutoSyncItemResult = { + organizationId: string; + provider: IntegrationProviderId; + ok: boolean; + summary: string; + error: string | null; + durationMs: number; +}; + +export type AutoSyncRunResult = { + trigger: AutoSyncTrigger; + startedAt: string; + finishedAt: string; + /** 符合條件(已開啟、狀態正常、自動同步沒關)而實際跑了的 組織 × 整合。 */ + results: AutoSyncItemResult[]; + /** 已開啟且正常,但 config.autoSync = false 而跳過的。 */ + skippedAutoSyncOff: { organizationId: string; provider: IntegrationProviderId }[]; +}; + +/** 操作紀錄的寫法。預設是 system(cron);手動觸發時由呼叫端記成 web / mcp 的那位成員。 */ +export type AutoSyncAuditFn = ( + orgId: string, + provider: IntegrationProviderId, + ok: boolean, + summary: string, +) => Promise; + +export type RunScheduledSyncOptions = { + /** 只跑這個組織(手動觸發)。省略 = 全部組織(cron)。 */ + orgId?: string; + trigger?: AutoSyncTrigger; + audit?: AutoSyncAuditFn; +}; + +const systemAudit: AutoSyncAuditFn = async (orgId, provider, ok, summary) => { + await logSystem( + orgId, + "update", + "integration", + null, + `${provider}: 自動同步${ok ? "" : "失敗"}:${summary}`, + ); +}; + +function errorMessage(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +/** 這次要同步的日期與年份(全部以台北日曆為準)。 */ +export function autoSyncWindow(now: Date): { + startDate: string; + endDate: string; + salaryYears: number[]; +} { + const endDate = taipeiDate(now); + const startDate = format(addDays(parseISO(endDate), -INVOICE_LOOKBACK_DAYS), "yyyy-MM-dd"); + const year = Number(endDate.slice(0, 4)); + const month = Number(endDate.slice(5, 7)); + // 一月時去年 12 月的薪資(次月 5 日發)可能才剛申報,去年也要再跑一次。 + const salaryYears = month === 1 ? [year - 1, year] : [year]; + return { startDate, endDate, salaryYears }; +} + +type StepOutcome = { + ok: boolean; + text: string; + error: string | null; + /** 失敗原因是「整合不可用」(剛被關掉、需要重新連接)—— 狀態本身已經說明,不再記 last_error。 */ + unavailable?: boolean; +}; + +async function step(label: string, fn: () => Promise): Promise { + try { + return { ok: true, text: `${label}:${await fn()}`, error: null }; + } catch (e) { + const msg = errorMessage(e); + return { + ok: false, + text: `${label}:失敗(${msg})`, + error: msg, + unavailable: e instanceof IntegrationUnavailableError, + }; + } +} + +async function syncSimpany(orgId: string, now: Date): Promise { + const { startDate, endDate, salaryYears } = autoSyncWindow(now); + const out: StepOutcome[] = []; + out.push( + await step(`發票 ${startDate}~${endDate}`, async () => { + const r = await syncSimpanyInvoices(orgId, { startDate, endDate }); + const parts = [ + `讀到 ${r.seen} 張`, + `新增 ${r.created}`, + `更新 ${r.updated}`, + `作廢 ${r.voided}`, + `自動綁定 ${r.autoLinked.length}`, + `待確認 ${r.needsReview.length}`, + ]; + if (r.incomplete) parts.push("未抓完,明天會接著做"); + return parts.join("、"); + }), + ); + // 發票那一步若是憑證被拒(整合已轉 needs_reauth),薪資一定也會失敗;照跑一次也只是 + // 再得到同一個「請重新連接」的錯誤,不會多打 Simpany(requireEnabledIntegration 先擋)。 + for (const year of salaryYears) { + out.push( + await step(`薪資申報 ${year}`, async () => { + const r = await syncSalaryDeclarations(orgId, year); + const parts = [`寫入 ${r.declarationsUpserted} 筆`]; + if (r.declarationsRemoved > 0) parts.push(`移除 ${r.declarationsRemoved} 筆`); + if (r.unmatchedNames.length > 0) parts.push(`${r.unmatchedNames.length} 位對不到員工`); + return parts.join("、"); + }), + ); + } + return out; +} + +async function syncWise(orgId: string, config: IntegrationConfig): Promise { + // 還沒設帳戶對應時 syncWiseTransactions 會丟「請先設定對應」—— 那不是失敗,是還沒設定; + // 直接略過,也不打 Wise。 + const cfg = parseWiseConfig(config); + if (!cfg.accountMappings.some((m) => m.bankAccountId !== null)) { + return [{ ok: true, text: "交易:尚未設定 Wise 帳戶對應,略過", error: null }]; + } + return [ + await step("交易", async () => { + const r = await syncWiseTransactions(orgId, { dryRun: false }); + const parts = [ + `新增 ${r.totals.created} 筆(待確認)`, + `已存在 ${r.totals.alreadySynced}`, + `切換日前 ${r.totals.beforeCutover}`, + ]; + if (r.skippedBalances.length > 0) { + const reasons = r.skippedBalances.map((s) => `${s.currency} ${s.reason}`).join(", "); + parts.push(`略過餘額 ${r.skippedBalances.length}(${reasons})`); + } + return parts.join("、"); + }), + ]; +} + +async function runOne( + orgId: string, + provider: IntegrationProviderId, + config: IntegrationConfig, + now: Date, + trigger: AutoSyncTrigger, + audit: AutoSyncAuditFn, +): Promise { + const started = Date.now(); + let steps: StepOutcome[]; + try { + steps = provider === "simpany" ? await syncSimpany(orgId, now) : await syncWise(orgId, config); + } catch (e) { + // step() 已經各自收斂錯誤,這裡只會接到真正意外的例外(例如 config 解析炸掉)。 + const msg = errorMessage(e); + steps = [{ ok: false, text: `失敗(${msg})`, error: msg }]; + } + + const ok = steps.every((s) => s.ok); + const summary = steps.map((s) => s.text).join(";"); + const failed = steps.find((s) => !s.ok); + const error = failed?.error ?? null; + + // provider 程式碼本身在 401 / 5xx 時已經 markNeedsReauth / recordSyncFailure; + // 這裡補記的是「本地」失敗(寫 DB、解析…),那些不會經過 provider 的標記。 + // 整合不可用(剛被關掉、需要重新連接)時狀態已經說明一切,不再覆寫 last_error。 + if (failed && !failed.unavailable) { + try { + await recordSyncFailure(orgId, provider, `自動同步失敗:${error}`); + } catch { + // 記錄失敗不影響其他整合 + } + } + + const last: LastAutoSync = { + at: new Date().toISOString(), + ok, + summary, + error, + trigger, + }; + try { + await updateConfig(orgId, provider, { lastAutoSync: last }); + } catch (e) { + console.error(`[autosync] ${orgId}/${provider}: failed to save lastAutoSync: ${errorMessage(e)}`); + } + try { + await audit(orgId, provider, ok, summary); + } catch { + // 操作紀錄失敗不影響同步 + } + + return { + organizationId: orgId, + provider, + ok, + summary, + error, + durationMs: Date.now() - started, + }; +} + +/** + * 跑一輪自動同步。依 organization_id、provider 排序,逐一執行。 + * 永遠不丟錯(除了連列出整合都失敗 —— 那代表 DB 不通,讓呼叫端知道)。 + */ +export async function runScheduledSync( + now: Date = new Date(), + opts: RunScheduledSyncOptions = {}, +): Promise { + const trigger = opts.trigger ?? "cron"; + const audit = opts.audit ?? systemAudit; + return runAsSystem(async () => { + const startedAt = new Date().toISOString(); + const where = opts.orgId + ? and( + eq(orgIntegrations.enabled, true), + eq(orgIntegrations.status, "connected"), + eq(orgIntegrations.organizationId, opts.orgId), + ) + : and(eq(orgIntegrations.enabled, true), eq(orgIntegrations.status, "connected")); + const rows = await getDb() + .select({ + organizationId: orgIntegrations.organizationId, + provider: orgIntegrations.provider, + config: orgIntegrations.config, + }) + .from(orgIntegrations) + .where(where) + .orderBy(asc(orgIntegrations.organizationId), asc(orgIntegrations.provider)); + + const results: AutoSyncItemResult[] = []; + const skippedAutoSyncOff: AutoSyncRunResult["skippedAutoSyncOff"] = []; + for (const row of rows) { + const provider = row.provider as IntegrationProviderId; + if (!supportsAutoSync(provider)) continue; + const config = (row.config ?? {}) as IntegrationConfig; + if (!isAutoSyncOn(config)) { + skippedAutoSyncOff.push({ organizationId: row.organizationId, provider }); + continue; + } + const r = await runOne(row.organizationId, provider, config, now, trigger, audit); + console.log( + `[autosync] ${r.organizationId}/${r.provider} ${r.ok ? "ok" : "FAILED"} ${r.durationMs}ms: ${r.summary}`, + ); + results.push(r); + } + return { trigger, startedAt, finishedAt: new Date().toISOString(), results, skippedAutoSyncOff }; + }); +} diff --git a/src/lib/integrations/store.ts b/src/lib/integrations/store.ts index 2fa6461..0554b12 100644 --- a/src/lib/integrations/store.ts +++ b/src/lib/integrations/store.ts @@ -1,5 +1,5 @@ import { and, eq, sql } from "drizzle-orm"; -import { getTranslations } from "next-intl/server"; +import { getServerT } from "@/i18n/server-t"; import { getDb } from "@/db"; import { orgIntegrations } from "@/db/schema"; import { user } from "@/db/auth-schema"; @@ -71,7 +71,7 @@ function whereRow(orgId: string, provider: IntegrationProviderId) { /** 整合在目前語系下的顯示名稱(integrations.providers..name)。 */ export async function integrationDisplayName(provider: IntegrationProviderId): Promise { - const t = await getTranslations("integrations"); + const t = await getServerT("integrations"); return t(`providers.${provider}.name`); } @@ -151,7 +151,7 @@ export async function requireEnabledIntegration( provider: IntegrationProviderId, ): Promise<{ row: IntegrationSummary; credentials: IntegrationCredentials }> { const row = await getIntegration(orgId, provider); - const t = await getTranslations("integrations"); + const t = await getServerT("integrations"); const name = t(`providers.${provider}.name`); if (!row) { throw new IntegrationUnavailableError(provider, "not_connected", t("errors.unavailable", { name })); diff --git a/src/lib/mcp/handler.ts b/src/lib/mcp/handler.ts index 8a98a5b..e7743bd 100644 --- a/src/lib/mcp/handler.ts +++ b/src/lib/mcp/handler.ts @@ -75,7 +75,7 @@ function deriveMcpAudit(name: string, out: unknown): McpAudit | null { /** Bump on every published change to tools, schemas or instructions. Clients * (and OpenAI's plugin "Scan Tools") key their cached snapshot off this. */ -export const SERVER_VERSION = "1.7.0"; +export const SERVER_VERSION = "1.8.0"; /** Public base URL of this deployment; doubles as the OAuth issuer. * Keep in sync with the `resource` passed to `mcp()` in src/lib/auth.ts. */ @@ -281,6 +281,9 @@ const OPENWORLD_OVERRIDES: Record> = { // our tables, so it keeps the closed-world default. simpany_list_salary_declarations: { openWorldHint: true }, simpany_sync_salary_declarations: { openWorldHint: true }, + // Runs the daily auto-sync for the caller's org: Simpany invoice + salary sync + // (GET-only for this path) and Wise transaction sync (GET-only); writes only our books. + run_integration_sync: { openWorldHint: true }, }; // Writes that are irreversible from MCP even though the verb isn't "delete". @@ -330,6 +333,7 @@ const TITLE_OVERRIDES: Record = { mark_accountant_notified: "Mark as sent to the accountant", salary_arrears: "Salary arrears by employee", pay_employee_salary: "Record a salary payslip", + run_integration_sync: "Run the integration auto-sync now", sync_billing_calendar: "Sync the billing calendar", simpany_get_invoice: "Simpany e-invoice detail", simpany_issue_invoice: "Issue a Simpany e-invoice", diff --git a/src/lib/mcp/tools-integrations.ts b/src/lib/mcp/tools-integrations.ts index b9f7177..c8dda46 100644 --- a/src/lib/mcp/tools-integrations.ts +++ b/src/lib/mcp/tools-integrations.ts @@ -1,5 +1,12 @@ import { getTranslations } from "next-intl/server"; import { logMcp, type ActivityAction } from "@/db/activity"; +import { canManageOrg, getOrgRole } from "@/lib/session"; +import { runScheduledSync } from "@/lib/integrations/autosync"; +import { + isAutoSyncOn, + parseLastAutoSync, + supportsAutoSync, +} from "@/lib/integrations/autosync-config"; import { INTEGRATION_ORDER } from "@/lib/integrations/catalog"; import { getProvider } from "@/lib/integrations/registry"; import { listIntegrations, requireEnabledIntegration } from "@/lib/integrations/store"; @@ -21,8 +28,9 @@ import { // ---- 外部整合(org_integrations)---- // -// 這裡只有「看狀態」的 list_integrations。連接 / 開關 / 中斷一律在 web 的 -// 設定 › 整合 做:要輸入憑證,而憑證不該經過 AI 對話。 +// 這裡有「看狀態」的 list_integrations,與「立即跑一次每日自動同步」的 +// run_integration_sync(owner / admin、只跑目前組織,與 cron 同一條程式碼路徑)。 +// 連接 / 開關 / 中斷一律在 web 的 設定 › 整合 做:要輸入憑證,而憑證不該經過 AI 對話。 // // 各 provider 的業務工具(開發票、抓 Wise 交易…)放在各自的 tools-.ts, // 並遵守同一套規則: @@ -98,6 +106,38 @@ const INTEGRATION_ROW = rowSchema({ }, lastSyncedAt: { type: ["string", "null"], description: "ISO 8601; last successful call to the service." }, lastError: { type: ["string", "null"], description: "Most recent failure message, if any." }, + autoSync: { + type: ["boolean", "null"], + description: + "Daily auto-sync switch (06:00 Asia/Taipei). true = on (the default), false = an owner/admin switched it off. null when the provider has no auto-sync or is not connected. Only runs while the integration is also enabled and healthy.", + }, + lastAutoSync: { + anyOf: [ + { + type: "object", + properties: { + at: { type: "string", description: "ISO 8601." }, + ok: { type: "boolean" }, + summary: { type: "string", description: "What was synced (zh-TW)." }, + error: { type: ["string", "null"] }, + trigger: { type: "string", enum: ["cron", "manual"] }, + }, + required: ["at", "ok", "summary", "error", "trigger"], + additionalProperties: false, + }, + { type: "null" }, + ], + description: "Result of the most recent auto-sync run for this integration (scheduled or manual); null if it never ran.", + }, +}); + +const AUTO_SYNC_ITEM = rowSchema({ + organizationId: { type: "string" }, + provider: { type: "string", enum: [...INTEGRATION_PROVIDER_IDS] }, + ok: { type: "boolean" }, + summary: { type: "string", description: "What was synced (zh-TW), one clause per step." }, + error: { type: ["string", "null"], description: "First failure message, if any." }, + durationMs: { type: "number" }, }); export const integrationTools: Record = { @@ -133,9 +173,66 @@ export const integrationTools: Record = { tokenExpiresAt: iso(s?.tokenExpiresAt ?? null), lastSyncedAt: iso(s?.lastSyncedAt ?? null), lastError: s?.lastError ?? null, + autoSync: s && supportsAutoSync(provider) ? isAutoSyncOn(s.config) : null, + lastAutoSync: s ? parseLastAutoSync(s.config) : null, }; }), ); }, }, + + run_integration_sync: { + description: + "Run the daily integration auto-sync right now for THIS organization only (owner/admin only) — exactly what the 06:00 Asia/Taipei scheduled run does, same code path. For each integration that is switched on, healthy and has auto-sync on: Simpany → import e-invoices of the last 90 days into invoices (+ auto-link) and this year's salary declarations (January: last year too); Wise → import balance-statement transactions into the ledger for mapped balances (deduped by Wise reference, never before the cutover date, new rows flagged needsReview). Reads the third parties (Simpany GET-only for this, Wise GET-only) and writes only this organization's own books; it NEVER issues or voids invoices and moves no money. Idempotent. Results are also stored per integration as lastAutoSync (see list_integrations). Can take a while (sequential, gentle on Simpany).", + inputSchema: { + type: "object", + properties: { ...ORG_ARG }, + additionalProperties: false, + }, + outputSchema: { + type: "object", + properties: { + trigger: { type: "string", enum: ["cron", "manual"] }, + startedAt: { type: "string", description: "ISO 8601." }, + finishedAt: { type: "string", description: "ISO 8601." }, + results: { type: "array", items: AUTO_SYNC_ITEM }, + skippedAutoSyncOff: { + type: "array", + description: "Enabled integrations skipped because an owner/admin switched auto-sync off.", + items: rowSchema({ + organizationId: { type: "string" }, + provider: { type: "string", enum: [...INTEGRATION_PROVIDER_IDS] }, + }), + }, + }, + required: ["trigger", "startedAt", "finishedAt", "results", "skippedAutoSyncOff"], + additionalProperties: false, + }, + annotations: { + title: "Run the integration auto-sync now", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + execute: async (args, ctx) => { + const orgId = await resolveOrg(args, ctx); + const role = await getOrgRole(orgId, ctx.userId); + if (!canManageOrg(role)) { + throw new Error("只有組織的擁有者或管理員可以執行整合自動同步,請找 owner 或 admin 操作。"); + } + return runScheduledSync(new Date(), { + orgId, + trigger: "manual", + audit: (auditOrgId, provider, ok, summary) => + auditIntegrationCall( + ctx, + auditOrgId, + provider, + "update", + `手動執行自動同步${ok ? "" : "(失敗)"}:${summary}`, + ), + }); + }, + }, }; diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index e39ec36..d60391e 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -95,7 +95,11 @@ const ACTIVITY_ROW: JsonSchemaObject = rowSchema({ id: { type: "number" }, actorName: { type: ["string", "null"] }, actorEmail: { type: ["string", "null"] }, - channel: { type: "string", enum: ["web", "mcp"] }, + channel: { + type: "string", + enum: ["web", "mcp", "system"], + description: "system = scheduled work with no human actor (e.g. the daily integration auto-sync); actor fields are null.", + }, action: { type: "string", enum: ["create", "update", "delete", "read"] }, entityType: { type: "string" }, entityId: { type: ["number", "null"] }, diff --git a/src/proxy.ts b/src/proxy.ts index e71d368..0274425 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -32,12 +32,16 @@ export function proxy(request: NextRequest) { // and published as `resource_policy_uri` / `resource_tos_uri` in the RFC 9728 // metadata, where OpenAI's plugin review fetches them without any session. // +// /api/cron 是 Cloudflare Cron Trigger 的內部進入點(worker.ts 的 scheduled() 在同一個 +// isolate 裡直接呼叫,沒有 session cookie);它自己用一次性 token 驗證,外部請求一律 404。 +// 見 src/lib/cron-token.ts。 +// // /flags 是 public/flags 那套國旗圖示(幣別與語系切換器都吃它)。正式環境其實碰不到 // 這裡 —— Cloudflare 的 assets binding 會在 Worker 之前就把 public/ 的檔案送出去 —— // 但 `next dev` 沒有那一層,於是同一張圖在本機會被導去 /login,公開頁面的語系切換器 // 在開發時就少了旗子。列進來讓本機跟線上看到的是同一件事。 export const config = { matcher: [ - "/((?!login|signup|landing|privacy|terms|flags|api/auth|mcp|.well-known|_next/static|_next/image|favicon.ico).*)", + "/((?!login|signup|landing|privacy|terms|flags|api/auth|api/cron|mcp|.well-known|_next/static|_next/image|favicon.ico).*)", ], }; diff --git a/worker.ts b/worker.ts new file mode 100644 index 0000000..2969756 --- /dev/null +++ b/worker.ts @@ -0,0 +1,75 @@ +// Cloudflare Worker 的進入點(wrangler.jsonc 的 `main`)。 +// +// OpenNext 產生的 .open-next/worker.js 只有 `fetch`。這支照 OpenNext 文件的 +// 「custom worker」做法把它包起來:fetch 原封不動轉給 OpenNext,再加上 Cron Trigger +// 需要的 `scheduled`。見 https://opennext.js.org/cloudflare/howtos/custom-worker +// +// scheduled 刻意不直接 import 業務程式碼(src/lib/…):那些程式碼依賴 Next 的 request +// context(cookies / headers / next-intl),而且 wrangler 會把它們再打包一份。 +// 改成在同一個 isolate 裡「以函式呼叫」OpenNext 的 fetch,打一條內部 route +// (/api/cron/integrations-autosync),同步就跑在一般的 Next route handler 裡: +// process.env 由 OpenNext 從 env 填好(字串型別的 vars / secrets)、DB、i18n 都與網頁 +// 請求相同。route 用一次性 token 驗證(src/lib/cron-token.ts),不需要額外的 secret。 + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment -- 檔案存在時不是錯誤,@ts-expect-error 會反過來報錯 +// @ts-ignore -- `.open-next/worker.js` 由 `bun run cf:build` 產生,型別檢查時可能還不存在 +import openNextHandler from "./.open-next/worker.js"; +import { + AUTOSYNC_CRON_PATH, + CRON_TOKEN_HEADER, + issueCronToken, + revokeCronToken, +} from "./src/lib/cron-token"; + +type Env = CloudflareEnv & Record; + +type OpenNextHandler = { + fetch: (request: Request, env: Env, ctx: ExecutionContext) => Promise; +}; +const handler = openNextHandler as OpenNextHandler; + +/** 內部請求用的 origin。OpenNext 會拿 isolate 的第一個請求決定 origin,所以要用正式網址。 */ +function internalOrigin(env: Env): string { + const configured = typeof env.BETTER_AUTH_URL === "string" ? env.BETTER_AUTH_URL : ""; + try { + return new URL(configured || "https://internal.pathors.com").origin; + } catch { + return "https://internal.pathors.com"; + } +} + +async function runIntegrationAutoSync(controller: ScheduledController, env: Env, ctx: ExecutionContext) { + const token = issueCronToken(); + const started = Date.now(); + try { + const request = new Request(`${internalOrigin(env)}${AUTOSYNC_CRON_PATH}`, { + method: "POST", + headers: { [CRON_TOKEN_HEADER]: token, "x-cron": controller.cron }, + }); + const response = await handler.fetch(request, env, ctx); + const body = await response.text(); + if (!response.ok) { + // 丟錯讓 Cloudflare 把這次 cron 標成失敗(dashboard 的 Cron Events 看得到)。 + throw new Error(`integrations autosync returned ${response.status}: ${body.slice(0, 500)}`); + } + console.log(`[cron] integrations autosync done in ${Date.now() - started}ms: ${body.slice(0, 2000)}`); + } finally { + revokeCronToken(token); + } +} + +export default { + fetch: (request: Request, env: Env, ctx: ExecutionContext) => handler.fetch(request, env, ctx), + + async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) { + // 目前只有一個排程(wrangler.jsonc triggers.crons),新增排程時依 controller.cron 分流。 + await runIntegrationAutoSync(controller, env, ctx); + }, +} satisfies ExportedHandler; + +// OpenNext 的 worker.js 會匯出這幾個 Durable Object class(快取 / revalidation 用)。 +// 這個專案目前沒有綁定它們,但照 OpenNext 的 custom worker 範例原樣轉出,之後開了 +// 相關快取設定也不用回來改這裡。 +// eslint-disable-next-line @typescript-eslint/ban-ts-comment -- 同上 +// @ts-ignore -- 同上,build 時才產生 +export { DOQueueHandler, DOShardedTagCache, BucketCachePurge } from "./.open-next/worker.js"; diff --git a/wrangler.jsonc b/wrangler.jsonc index 6f00db9..1af6c90 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -10,7 +10,9 @@ // 或用 `wrangler deploy --config <你的檔案>` 指到另一份設定。 "$schema": "node_modules/wrangler/config-schema.json", "name": "pathors-internal", - "main": ".open-next/worker.js", + // 自訂的 worker 進入點:轉發 OpenNext 產生的 .open-next/worker.js(fetch), + // 再加上 Cron Trigger 的 scheduled。見 worker.ts 與 docs/deployment.md。 + "main": "worker.ts", "compatibility_date": "2025-08-01", "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], "workers_dev": true, @@ -38,6 +40,9 @@ "enabled": false } }, + // 整合每日自動同步:22:00 UTC = 台北 06:00(Cron Trigger 一律以 UTC 計)。 + // scheduled() 在 worker.ts;同步內容見 docs/integrations.md「每日自動同步」。 + "triggers": { "crons": ["0 22 * * *"] }, "vars": { "NEXT_PUBLIC_APP_NAME": "Pathors Internal" }