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
4 changes: 4 additions & 0 deletions docs/DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ web build 的 `VITE_API_BASE`、以及 Cloudflare Access application 的 domain

## 2. 建立 Cloudflare 資源(D1 / R2)

> **R2 為選填。** 若不需要「成員上傳繳費截圖」,可跳過建立 R2,並移除 `wrangler.toml` 的 `[[r2_buckets]]` 區塊。
> 未綁 R2 時停用:成員上傳/後台檢視繳費截圖、截圖自動保存清理;其餘(宣告繳費、後台審核、對帳、Discord 通知)一切正常。
> 後台首次進入會跳一次提示,設定頁也會顯示「截圖儲存(R2):未啟用」。

### 路徑一(後台操作)

1. 進 [Cloudflare 後台](https://dash.cloudflare.com) → **Storage & Databases → D1**。
Expand Down
621 changes: 621 additions & 0 deletions docs/superpowers/plans/2026-06-07-r2-optional.md

Large diffs are not rendered by default.

105 changes: 105 additions & 0 deletions docs/superpowers/specs/2026-06-07-r2-optional-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# R2 改為選填 — 設計

> 日期:2026-06-07 狀態:設計定稿,待寫實作計畫
> 功能 1(兩個體驗優化之一;功能 2「成員/訂閱刪除 CRUD」另立 spec)

## 1. 背景與目標

有些部署者不需要「成員上傳繳費截圖」功能,因此不想註冊 Cloudflare R2。目前 `env.BUCKET`
(R2 binding)是**必填**,程式多處直接使用,未綁 R2 會壞。目標:

1. **R2 變選填**:不註冊 R2 binding 時,整個系統照常運作,只有「截圖佐證」相關功能優雅停用。
2. **後台首次提示**:未設定 R2 時,管理者第一次進後台彈一次提示,說明哪些功能不可用(只彈一次)。
3. **常駐狀態**:設定頁顯示一個常駐的「截圖儲存:未啟用 / 已啟用」狀態,方便日後查看。

**宣告繳費本身不受影響**:選渠道、填備註、後台審核(verify/reject/改金額)、對帳、Discord 開繳/催繳全部照常。

## 2. 關鍵設計決策

- **偵測方式=binding 是否存在**(`!!env.BUCKET`),不另設 workspace 開關。R2 是基礎設施設定,
forker 不在 `wrangler.toml` 註冊 `[[r2_buckets]]` 即視為未啟用;零額外狀態、不會「設定說有但實際沒綁」矛盾。
- **無 R2 時截圖一律隱藏/忽略**(成員端),既有「宣告繳費」路徑全程可用。
- **首次提示用 localStorage**(admin SPA 目前未用 localStorage);只彈一次。

## 3. R2 依賴點與停用後行為

`env.BUCKET` 目前 4 處使用 → 改 `BUCKET?: R2Bucket`(選填)並逐處 guard:

| 位置 | 現行 | 無 R2 時 |
|---|---|---|
| `core/storage.ts` `settleUserPeriod` step 3(存 proof) | `putObject(env.BUCKET, …)` | 有 proof 也跳過儲存,付款宣告照常寫入(`has_proof=0`、`screenshot_key=NULL`),不報錯 |
| `routes/images.ts`(看截圖) | `env.BUCKET.get(key)` | 回 404(截圖功能停用) |
| `routes/admin.ts` `deleteProof` | `env.BUCKET.delete(key)` | 無 BUCKET 或無 proof → 安全 no-op(仍清 DB 欄位) |
| `core/retention.ts`(cron) | `env.BUCKET.delete(...)` | 整段跳過、回 0 |

> `storage.ts` 的 `deleteObject` 補償清理(:201、:219)也在 `if (key)` 內,key 為 null 時自然略過;
> 但因 step 3 已 guard,無 R2 時 key 恆為 null。

## 4. 元件設計

### A. 後端偵測旗標
- `env.ts`:`BUCKET?: R2Bucket`。
- `routes/admin.ts` `getWorkspace`:回應由 `{ workspace }` 擴充為 `{ workspace, r2_configured: !!env.BUCKET }`。
- `routes/upload.ts` `handleUploadInfo`(`GET /upload/:token`):回應加 `proof_enabled: !!env.BUCKET`。

### B. 成員端截圖 UX
- **web 上傳頁**(`packages/web`):`TokenInfo` 型別加 `proof_enabled?: boolean`;`App.tsx` 依此**隱藏截圖欄位與相關說明**,只留渠道+備註。`api.ts` 的 `submitPayment` 在停用時不附 `screenshot`。
- **Discord `/繳費`**(`adapters/discord/handler.ts`):附了截圖但無 R2 → 不傳 proof 給 `settleUserPeriod`(或傳了也被 storage guard 跳過),ephemeral 回覆加一句「本站未開啟截圖功能,已記錄你的繳費宣告」。
- **core 防線**:`settleUserPeriod` step 3 改為 `if (input.proof && env.BUCKET)` 才 `putObject`;這是無論前端如何都成立的單一真相 guard。

### C. 後台首次提示(功能 1b/1c)
- admin SPA 頂層(`App.tsx`)載入後:若 `r2_configured===false` 且 `localStorage.getItem("chippot.r2NoticeSeen")` 不存在 → 顯示一次 Modal。
- Modal 內容:標題「Cloudflare R2 尚未設定」;說明停用的功能(成員上傳/檢視繳費截圖、截圖自動保存清理);一句「不影響:宣告繳費、後台審核、對帳、Discord 通知」;關閉鈕。
- 關閉時 `localStorage.setItem("chippot.r2NoticeSeen", "1")`,不再彈。

### D. 常駐狀態(功能採納項)
- 設定頁(`views/Settings.tsx`)新增一行常駐狀態:**截圖儲存(R2):✅ 已啟用 / ⚠️ 未啟用**,由 `api.workspace()` 回的 `r2_configured` 驅動。未啟用時附一句「成員無法上傳截圖;其餘功能正常」。

### E. 設定 / 文件
- `wrangler.toml`:`[[r2_buckets]]` 區塊上方加註解「選填:不需要成員上傳截圖可移除此區塊(R2 未綁時截圖功能自動停用)」。
- `docs/DEPLOY.md`:第 2 節 R2 標為**選填**;新增說明「移除 R2 binding 後,成員上傳/檢視截圖與保存清理停用,其餘照常」。

## 5. 介面 / 檔案異動清單

| 檔案 | 異動 |
|---|---|
| `packages/worker/src/env.ts` | `BUCKET?: R2Bucket`(選填) |
| `packages/worker/src/core/storage.ts` | `settleUserPeriod` step 3 存 proof 加 `&& env.BUCKET` guard |
| `packages/worker/src/routes/images.ts` | 無 BUCKET → 404 |
| `packages/worker/src/routes/admin.ts` | `deleteProof` guard;`getWorkspace` 回 `r2_configured` |
| `packages/worker/src/core/retention.ts` | 無 BUCKET → 跳過、回 0 |
| `packages/worker/src/routes/upload.ts` | `handleUploadInfo` 回 `proof_enabled` |
| `packages/worker/src/adapters/discord/handler.ts` | `/繳費` 無 R2 時忽略附件 + 提示 |
| `packages/web/src/api.ts` | `TokenInfo.proof_enabled`;`submitPayment` 停用時不附截圖 |
| `packages/web/src/App.tsx` | 依 `proof_enabled` 隱藏截圖欄位 |
| `packages/admin/src/api.ts` | `workspace()` 回傳型別加 `r2_configured` |
| `packages/admin/src/App.tsx` | 首次 R2 提示 Modal(localStorage 一次性) |
| `packages/admin/src/views/Settings.tsx` | 常駐 R2 狀態列 |
| `packages/worker/wrangler.toml` | `[[r2_buckets]]` 加「選填」註解 |
| `docs/DEPLOY.md` | R2 標選填 + 停用功能說明 |

## 6. 測試

既有 R2 測試不受影響(vitest 測試環境仍提供 BUCKET binding)。新增(以 `(env as any).BUCKET = undefined` 模擬未綁):
- `settleUserPeriod`:附 proof 但無 BUCKET → 仍成功結算、`has_proof=0`、`screenshot_key=NULL`、不丟例外。
- `images` 路由:無 BUCKET → 404。
- `retention`:無 BUCKET → 回 0、不呼叫刪除。
- `GET /admin/workspace`:回 `r2_configured`(有 BUCKET=true);模擬無 BUCKET=false。
- `GET /upload/:token`:回 `proof_enabled` 對應 BUCKET 有無。
- `deleteProof`:無 BUCKET → 仍清 DB 欄位、不丟例外。

> 注意:vitest pool 的 BUCKET 來自設定;逐測試以 save/restore `(env as any).BUCKET` 模擬未綁(沿用既有 token save/restore 範式),避免污染其他測試。

## 7. 風險與權衡

- **既有截圖資料**:本變更只影響「未綁 R2」的部署;已綁 R2 者行為完全不變。
- **Discord slash option**:`/繳費` 的「截圖」option 是靜態註冊、無法依 R2 動態隱藏,故採「忽略附件 + 提示」而非隱藏(已於 §4B 說明)。
- **localStorage 一次性**:清掉瀏覽器儲存會再彈一次;可接受(符合「首次提示」語意)。常駐狀態列提供長期可見性。

## 8. 決議摘要(brainstorming)

1. 偵測:**binding-absence**(`!!env.BUCKET`),不另設開關。
2. 成員截圖:**隱藏/忽略**,宣告繳費照常。
3. 首次提示:**localStorage 一次性 Modal**。
4. **加常駐 R2 狀態列**於設定頁。
5. 與「成員/訂閱刪除 CRUD」分為**兩份獨立 spec/PR**;本 spec 為功能 1。
26 changes: 25 additions & 1 deletion packages/admin/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { Dashboard } from "./views/Dashboard";
import { Payments } from "./views/Payments";
import { Users, Subscriptions, Plans, ChannelTags } from "./views/Manage";
import { Settings } from "./views/Settings";
import { IconLogout } from "./ui";
import { api } from "./api";
import { IconLogout, Modal } from "./ui";

const VIEWS = [
{ id: "dashboard", label: "對帳看板", el: <Dashboard /> },
Expand All @@ -15,6 +16,28 @@ const VIEWS = [
{ id: "settings", label: "設定", el: <Settings /> },
];

function R2Notice() {
const [show, setShow] = useState(false);
useEffect(() => {
if (localStorage.getItem("chippot.r2NoticeSeen")) return;
api.workspace().then((w) => { if (w && w.r2_configured === false) setShow(true); }).catch(() => {});
}, []);
if (!show) return null;
const dismiss = () => { localStorage.setItem("chippot.r2NoticeSeen", "1"); setShow(false); };
return (
<Modal title="Cloudflare R2 尚未設定" onClose={dismiss}>
<p>偵測到尚未綁定 R2 儲存空間,以下功能將無法使用:</p>
<ul style={{ margin: "8px 0 8px 18px" }}>
<li>成員上傳繳費截圖</li>
<li>後台檢視繳費截圖</li>
<li>截圖自動保存清理</li>
</ul>
<p className="muted small">不影響:宣告繳費、後台審核、對帳、Discord 通知。如需截圖功能,請在 wrangler.toml 註冊 R2 binding 後重新部署。</p>
<button className="btn btn--primary" onClick={dismiss}>我知道了</button>
</Modal>
);
}

export default function App() {
const [view, setView] = useState(() => window.location.hash.slice(1) || "dashboard");
useEffect(() => {
Expand All @@ -26,6 +49,7 @@ export default function App() {

return (
<div className="app">
<R2Notice />
<aside className="sidebar">
<div className="sidebar__brand">ChipPot</div>
<nav className="nav">
Expand Down
2 changes: 1 addition & 1 deletion packages/admin/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export interface User { id: number; display_name: string; discord_id: string | n
export interface Subscription { id: number; user_name: string; plan_name: string; status: string; start_date: string; billing_day: number; custom_cycle: number; user_id: number; plan_id: number }

export const api = {
workspace: () => req("GET", "/workspace"),
workspace: () => req<{ workspace: any; r2_configured: boolean }>("GET", "/workspace"),
updateWorkspace: (b: unknown) => req("PATCH", "/workspace", b),
rebuildPaymentMessage: () => req<{ message_id: string }>("POST", "/discord/payment-message"),
registerCommands: () => req<{ ok: boolean; registered: number }>("POST", "/discord/register-commands"),
Expand Down
10 changes: 9 additions & 1 deletion packages/admin/src/views/Settings.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { api, currentPeriod, nextBillingPeriod } from "../api";
import { useAsync, Card, Field, Empty, Modal } from "../ui";
import { useAsync, Card, Field, Empty, Modal, IconCheck, IconWarning } from "../ui";

const PLACEHOLDER_RE = /\{(\w+)\}/g;
const OVERDUE_KEYS = ["period", "count", "list"];
Expand Down Expand Up @@ -107,6 +107,14 @@ export function Settings() {
<div style={{ padding: "18px 20px", maxWidth: 460 }}>
{err && <div className="error-banner">{err}</div>}
{saved && <div style={{ color: "var(--teal)", marginBottom: 12 }}>✓ 已儲存</div>}
{data && (
<div style={{ marginBottom: 14, fontSize: 14 }}>
<span className="field__label">截圖儲存(R2):</span>{" "}
{(data as any).r2_configured
? <span style={{ color: "var(--teal)" }}><IconCheck /> 已啟用</span>
: <span style={{ color: "var(--muted)" }}><IconWarning /> 未啟用(成員無法上傳截圖;其餘功能正常)</span>}
</div>
)}
<Field label="統一結帳日 (1-28)"><input type="number" value={billingDay} onChange={(e) => setBillingDay(e.target.value)} disabled={busy} /></Field>
<Field label="逾期天數"><input type="number" value={overdue} onChange={(e) => setOverdue(e.target.value)} disabled={busy} /></Field>
<Field label="截圖保存月數 (retention)"><input type="number" value={retention} onChange={(e) => setRetention(e.target.value)} disabled={busy} /></Field>
Expand Down
4 changes: 3 additions & 1 deletion packages/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export default function App() {
</label>
)}

{info?.proof_enabled !== false && (
<label className={`drop ${preview ? "drop--has" : ""}`}>
<input
ref={fileRef}
Expand All @@ -141,6 +142,7 @@ export default function App() {
</div>
)}
</label>
)}

<textarea
className="note"
Expand All @@ -156,7 +158,7 @@ export default function App() {
<button className="submit" onClick={submit} disabled={busy || !canSubmit}>
{busy ? "上傳中…" : "送出繳費"}
</button>
<p className="muted small center">渠道、截圖或備註至少填一項。此連結僅限你本人本期使用,送出後即失效。</p>
<p className="muted small center">{info?.proof_enabled === false ? "渠道或備註至少填一項。" : "渠道、截圖或備註至少填一項。"}此連結僅限你本人本期使用,送出後即失效。</p>
</div>
</Shell>
);
Expand Down
1 change: 1 addition & 0 deletions packages/web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface TokenInfo {
user?: { display_name: string };
subscriptions?: SubscriptionChoice[];
channel_tags?: ChannelTag[];
proof_enabled?: boolean;
}

export async function fetchTokenInfo(token: string): Promise<TokenInfo> {
Expand Down
9 changes: 6 additions & 3 deletions packages/worker/src/adapters/discord/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,11 +180,12 @@ async function computePayResult(i: DiscordInteraction, env: Env): Promise<string
declaredChannelTagId = tagId;
}

// Optional screenshot.
// Optional screenshot — only when R2 is configured; otherwise ignore the attachment.
let proof: { body: ArrayBuffer; ext: string; contentType: string } | null = null;
const attachOpt = getOption(i, "截圖");
const attachment = attachOpt?.value ? i.data?.resolved?.attachments?.[attachOpt.value] : undefined;
if (attachment) {
const screenshotIgnored = !!attachment && !env.BUCKET;
if (attachment && env.BUCKET) {
const ct = attachment.content_type ?? "";
try { assertImageOk(ct, attachment.size ?? 0); }
catch (e) { if (e instanceof InvalidImage) return "截圖格式不支援或檔案過大,請改用備註或渠道。"; throw e; }
Expand All @@ -198,6 +199,7 @@ async function computePayResult(i: DiscordInteraction, env: Env): Promise<string

// At-least-one rule (slash): 渠道 / 截圖 / 備註.
if (!declaredChannelTagId && !proof && !note) {
if (screenshotIgnored) return "本站未開啟截圖功能,請改用「渠道」或「備註」登記繳費。";
return "請至少選擇「渠道」、附上「截圖」或填寫「備註」其中一項。";
}

Expand All @@ -206,7 +208,8 @@ async function computePayResult(i: DiscordInteraction, env: Env): Promise<string
declaredChannelTagId, paymentNote: note, proof,
});
if (r.paidCount === 0) return `本期(${period})已登記繳費,無需重複操作。`;
return `✅ 已登記本期(${period})繳費 NT$${r.totalAmount.toLocaleString()}(共 ${r.paidCount} 筆)。管理員確認收款後完成。`;
const ignoredNote = screenshotIgnored ? "(本站未開啟截圖功能,已記錄你的繳費宣告)" : "";
return `✅ 已登記本期(${period})繳費 NT$${r.totalAmount.toLocaleString()}(共 ${r.paidCount} 筆)。管理員確認收款後完成。${ignoredNote}`;
}

// ── 發起繳費 (admin): modal open + modal submit ──────────────────────────────
Expand Down
1 change: 1 addition & 0 deletions packages/worker/src/core/retention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export async function runRetention(
retentionMonths: number,
now: Date = new Date()
): Promise<number> {
if (!env.BUCKET) return 0; // R2 not configured — nothing to retain
const cutoffIso = subMonthsUtc(now, retentionMonths).toISOString();

const { results } = await env.DB
Expand Down
6 changes: 3 additions & 3 deletions packages/worker/src/core/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ export async function settleUserPeriod(env: Env, input: SettleInput): Promise<Se

// 3. Store the proof once (shared key) if present.
let key: string | null = null;
if (input.proof) {
if (input.proof && env.BUCKET) {
key = buildScreenshotKey(workspaceId, period, userId, input.proof.ext, crypto.randomUUID());
await putObject(env.BUCKET, key, input.proof.body, input.proof.contentType);
}
Expand All @@ -198,7 +198,7 @@ export async function settleUserPeriod(env: Env, input: SettleInput): Promise<Se
await applyDirectSettle(env, input, key, now);
}
} catch (err) {
if (key) await deleteObject(env.BUCKET, key).catch(() => {});
if (key && env.BUCKET) await deleteObject(env.BUCKET, key).catch(() => {});
throw err;
}

Expand All @@ -215,7 +215,7 @@ export async function settleUserPeriod(env: Env, input: SettleInput): Promise<Se
// TOCTOU guard: if a concurrent settle paid these rows between the settleTargets() snapshot
// and our UPDATE, the direct path can match 0 rows after we already stored the object —
// compensate so it isn't orphaned. (The token path throws before reaching here on 0 rows.)
if (paidRows.results.length === 0 && key) {
if (paidRows.results.length === 0 && key && env.BUCKET) {
await deleteObject(env.BUCKET, key).catch(() => {});
key = null;
}
Expand Down
3 changes: 2 additions & 1 deletion packages/worker/src/env.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export interface Env {
DB: D1Database;
BUCKET: R2Bucket;
// Optional: omit the [[r2_buckets]] binding in wrangler.toml to run without screenshots.
BUCKET?: R2Bucket;
// Cloudflare Access (secrets / vars; set via wrangler secret put or .dev.vars).
ACCESS_TEAM_DOMAIN?: string; // <team> in <team>.cloudflareaccess.com
ACCESS_AUD?: string; // Access application AUD tag
Expand Down
4 changes: 2 additions & 2 deletions packages/worker/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ async function getWorkspace(_req: Request, env: Env, ctx: RouteCtx): Promise<Res
const row = await env.DB.prepare("SELECT * FROM workspaces WHERE id = ?").bind(wsId(ctx))
.first<{ id: number; name: string; billing_day: number; settings: string }>();
if (!row) return errorResponse(404, "not found");
return json({ workspace: { ...row, settings: parseSettings(row.settings) } });
return json({ workspace: { ...row, settings: parseSettings(row.settings) }, r2_configured: !!env.BUCKET });
}

async function updateWorkspace(req: Request, env: Env, ctx: RouteCtx): Promise<Response> {
Expand Down Expand Up @@ -456,7 +456,7 @@ async function deleteProof(_req: Request, env: Env, ctx: RouteCtx): Promise<Resp
const id = Number(ctx.params.id);
const p = await getPayment(env.DB, id);
if (!p) return errorResponse(404, "not found");
if (p.screenshot_key) await env.BUCKET.delete(p.screenshot_key);
if (p.screenshot_key && env.BUCKET) await env.BUCKET.delete(p.screenshot_key);
await env.DB.prepare("UPDATE payments SET screenshot_key = NULL, proof_deleted_at = ?, updated_at = ? WHERE id = ?")
.bind(taipeiDate(), nowUtcIso(), id).run();
await writeAudit(env.DB, { workspaceId: p.workspace_id, actor: actorOf(ctx), action: "proof.delete", entityType: "payment", entityId: id, before: { screenshot_key: p.screenshot_key } });
Expand Down
2 changes: 2 additions & 0 deletions packages/worker/src/routes/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export async function handleImage(
const key = ctx.url.searchParams.get("key");
if (!key) return errorResponse(400, "key is required");

if (!env.BUCKET) return errorResponse(404, "not found");

const known = await env.DB
.prepare("SELECT 1 AS ok FROM payments WHERE screenshot_key = ?")
.bind(key)
Expand Down
Loading