Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <BETTER_AUTH_URL origin>/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 |
Expand Down
66 changes: 65 additions & 1 deletion docs/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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-<provider>.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
Expand Down
5 changes: 5 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/**",
]),
]);

Expand Down
14 changes: 14 additions & 0 deletions migrations/0028_activity_channel_system.sql
Original file line number Diff line number Diff line change
@@ -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']));
21 changes: 21 additions & 0 deletions src/app/api/cron/integrations-autosync/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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);
}
6 changes: 3 additions & 3 deletions src/app/dashboard/activity/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,11 @@ export default async function ActivityPage() {
{formatDateTime(r.createdAt)}
</TableCell>
<TableCell className="whitespace-nowrap">
{r.actorName ?? r.actorEmail ?? "—"}
{r.actorName ?? r.actorEmail ?? (r.channel === "system" ? t("systemActor") : "—")}
</TableCell>
<TableCell>
{r.channel === "mcp" ? (
<Badge variant="secondary">{t("source.mcp")}</Badge>
{r.channel === "mcp" || r.channel === "system" ? (
<Badge variant="secondary">{t(`source.${r.channel}`)}</Badge>
) : (
<span className="text-muted-foreground">{t("source.web")}</span>
)}
Expand Down
81 changes: 81 additions & 0 deletions src/app/dashboard/settings/integrations/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<IntegrationActionState> {
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<RunAutoSyncState> {
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") };
}
}
Loading
Loading