Conversation
原始目標一直沒達成:現在每位同學只訂閱自己的 ICS feed,但 Google Calendar 的訂閱式日曆無法被分享、也不參與 free/busy,所以同學之間互相看不到彼此的 空堂。這是唯一能讓大家在 Google Calendar 原生的「尋找時間」看到彼此空堂的 做法——事件必須寫進對方自己帳號的日曆,訂閱哪一種 ICS 都繞不過這點。 決策(部長已確認):學生自己 OAuth 授權,服務端把課表寫進他自己的主日曆 (primary),寫入在伺服器端做,學生按完同意就能關分頁;之後每天課表更新 由伺服器自動推送,學生不必再進站。這是 T-Pass 生態系第一個 OAuth 例外, 界線寫在 tpass-ops 的 AGENTS.md(配套 PR:tschoolsu/tpass-ops#13)。 ## 資料模型 持久層一併換成 Prisma/Postgres(原本 lib/db.ts 是 node:sqlite,不要兩套 持久層並存): - CalendarLink:既有的 ICS feed uuid,原封不動搬過來 - CalendarGrant:一位學生的 Google 授權,refreshToken 用 AES-256-GCM 加密 落地(lib/crypto.ts),pendingSince 非 null 就是「在同步佇列裡」——佇列 狀態是這個欄位本身,不用另開一張 job 表,重啟不掉 - SyncedEvent:已寫入的事件快照,key 用來算出 deterministic 的 Google event id(sha1(studentId+key) 轉 base32hex),本地資料全毀也能精準算出 要刪哪些 id,不會在學生日曆留幽靈課 ## 同步引擎 - lib/calendar-sync.ts:目標事件集合跟 lib/ics.ts 共用同一套「合併連續 節次」邏輯(courseEvents),跟上次同步快照 diff,只送真正變動的 insert/patch/delete。事件標 transparency:opaque(唯一目的:讓 free/busy 生效);scope 只能拿到 calendar.events 這麼寬(沒有更小的選項能只碰 主日曆上自己建的事件並保留 free/busy),所以查詢一律用 privateExtendedProperty=tschedule=1 過濾,永不列舉其他事件。 - lib/calendar-queue.ts:單一 worker(跟 ecosystem.config.js 的 instances:1 一致)+ token bucket 限速(CALENDAR_SYNC_RATE_PER_MIN, 預設 300/min,Google 專案上限 600/min)。失敗會排到 5 分鐘後重試,不會 對同一個壞掉的 grant 忙迴圈。 - lib/scheduler.ts:每天抓完課表後跟上次的 latest.json 比對,抓出新增/ 移除/時間或地點變動的課名,把「修了這些課、且已連結 Calendar」的學生 全部排入同步佇列——調課會影響一群人,不是一個人,這是刻意的行為。 ## 授權流程 /api/calendar/connect(帶簽章 + 短效的 state,見 lib/oauth-state.ts)→ Google 同意畫面(access_type=offline + prompt=consent,才保證拿得到 refresh_token)→ /api/calendar/oauth/callback 驗 state、換 token、核對 userinfo 的網域與學號跟 T-Pass session 一致(防止把課表灌進別人或校外的 帳號)→ 加密存 refresh token、排入佇列 → 導回首頁。 /api/calendar/disconnect:不信任本地 SyncedEvent 表可能的落差,改用 privateExtendedProperty 直接向 Google 問「本服務寫過哪些事件」刪光, 撤銷 token,清掉本地紀錄。 ## UI SettingsModal 加「Google Calendar 同步」區塊:未連結顯示連結按鈕; 已連結顯示 Google 帳號、上次同步時間、同步中/失效狀態、中斷連結。 既有的 ICS 訂閱區塊保留,補一句話說明它做不到「被尋找時間查到」這件事, 引導優先用 Calendar 同步。 驗證:pnpm install && next typegen && tsc --noEmit && lint && build 全過; tpass-skills/scripts/check.sh 全綠。依賴 #1(對齊 PR)先 merge。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
YC815
marked this pull request as draft
August 31, 2026 12:46
每次失敗當下的 console.error 會被逐筆日誌淹沒,看不出現在總共卡住幾個學生。 每 30 分鐘印一行總表(studentId + 錯誤訊息),靠既有的 tpass logs 就能看到規模, 不用另開 admin 介面或加欄位。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UsKQD8u2pb9g5jwMrv5Seb
只帶 prompt=consent 時,瀏覽器已登入多個 Google 帳號會直接沿用預設帳號, 完全跳過選擇畫面——連錯帳號要等 oauth/callback 的網域檢查才會發現。 加 select_account 強制每次都跳出選擇畫面。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UsKQD8u2pb9g5jwMrv5Seb
AGENTS 原本只有 Next 樣板警告,補上 Google Calendar OAuth 是全生態唯一例外、每日排程、 data/*.json 是狀態、deployed:false 尚未上線、Prisma 6 待升 7。README 壓成標準結構, 設計要點只留檔案指引。 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RiS9H5n64aLnfmfvC46b2T
…pm 10.27 不再讀舊位置) 之前每次 install 都警告,且 prisma/sharp 等的 build script 實際被略過。 順手讓 lockfile 跟上 package.json(@prisma/adapter-pg 的 ^7.10.0 specifier 之前沒寫進 lock, 主機 --frozen-lockfile 會直接失敗)。已驗證 pnpm install --frozen-lockfile 通過。 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RiS9H5n64aLnfmfvC46b2T
準則上線前置條件:driver adapter、池 max/timeout/statement_timeout、migrations。 baseline 由 migrate diff --from-empty 產生,主機既有庫首次部署前要 prisma migrate resolve --applied 0_init。getOrCreateCalendarLink 改 upsert, disconnect 兩步包 $transaction,同步 worker 處理 SIGINT/SIGTERM。刪 db:push。 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018dh8hHJpskiy2tdmpr7ZiX
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XQuuk3UMkMeejgFp391ejm
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
這是什麼
依賴
#1(對齊 PR,這條 PR 的 base就設成那條分支,等它 merge 進 main 之後這條會自動改指向 main)。
原始目標一直沒達成:現在每位同學只訂閱自己的 ICS feed,但 Google Calendar
訂閱式日曆無法被分享、也不參與 free/busy,所以同學之間互相看不到彼此的空堂。
這不是實作沒做好,是 Google 的產品限制——事件必須寫進對方自己帳號的日曆,
訂閱哪一種 ICS 都繞不過這點。
決策(部長已確認):學生自己 OAuth 授權,服務端把課表寫進他自己的主日曆
(primary)。寫入在伺服器端做,學生按完同意就能關分頁;之後每天課表更新由
伺服器自動推送,不必再進站。這是 T-Pass 生態系第一個 OAuth 例外,界線寫在
配套 PR
tpass-ops#13(建議跟這條一起看,兩條互相引用)。
資料模型
持久層一併換成 Prisma/Postgres——原本
lib/db.ts是node:sqlite,不留兩套持久層並存:CalendarLink:既有的 ICS feed uuid,原封不動搬過來CalendarGrant:一位學生的 Google 授權。refreshToken用 AES-256-GCM 加密落地(
lib/crypto.ts)——本表全量外洩也换不到任何人的 Google 帳號。pendingSince非 null就是「在同步佇列裡」,佇列狀態是這個欄位本身,不用另開 job 表,重啟不掉。
SyncedEvent:已寫入的事件快照。key 用來算出 deterministic 的 Google event id(
sha1(studentId+key)轉 base32hex)——本地資料全毀也能精準算出要刪哪些 id,不會在學生日曆留下幽靈課。
同步引擎
lib/calendar-sync.ts:目標事件集合跟lib/ics.ts共用同一套「合併連續節次」邏輯(
courseEvents),跟上次同步快照 diff,只送真正變動的 insert/patch/delete。事件標
transparency: opaque(唯一目的:讓 free/busy 生效)。calendar.events這麼寬(沒有更小的選項能只碰主日曆上自己建的事件並保留 free/busy),所以查詢一律用
privateExtendedProperty=tschedule=1過濾,永不列舉其他事件。
lib/calendar-queue.ts:單一 worker(跟ecosystem.config.js的instances:1一致)CALENDAR_SYNC_RATE_PER_MIN,預設 300/min,Google 專案上限600/min)。失敗排到 5 分鐘後重試,不會對同一個壞掉的 grant 忙迴圈。
lib/scheduler.ts:每天抓完課表後跟上次的latest.json比對,抓出新增/移除/時間或地點變動的課名,把「修了這些課、且已連結 Calendar」的學生全部排入同步佇列——
調課會影響一群人,不是一個人,這是刻意的行為,不是 bug。
授權流程
/api/calendar/connect(帶簽章 + 短效的 state,見lib/oauth-state.ts)→ Google 同意畫面(
access_type=offline+prompt=consent,才保證拿得到 refresh_token)→/api/calendar/oauth/callback驗 state、換 token、核對 userinfo 的網域與學號跟 T-Passsession 一致(防止把課表灌進別人或校外的帳號)→ 加密存 refresh token、排入佇列 → 導回首頁。
/api/calendar/disconnect:不信任本地SyncedEvent表可能的落差,改用privateExtendedProperty直接向 Google 問「本服務寫過哪些事件」刪光,撤銷 token,清掉本地紀錄。
UI
SettingsModal加「Google Calendar 同步」區塊:未連結顯示連結按鈕;已連結顯示 Google帳號、上次同步時間、同步中/失效狀態、中斷連結。既有 ICS 訂閱區塊保留,補一句話說明它
做不到「被尋找時間查到」這件事,引導優先用 Calendar 同步。
驗證
四項全過,
check.sh全綠。尚未做的驗證(需要真的 Google Workspace 帳號與已部署環境,留給部內審查時一起確認):
(這一步過不了,整個方案的前提就錯了)
之後
主機部署(
deployed: true)是所有相關 PR(tpass-registry#7、tpass-schedule#1、這條、tpass-ops#13)都 merge 之後的獨立動作,不在這條 PR 範圍——還需要人在學校 Workspace的 GCP 專案建 OAuth client(見
src/config/calendar.ts開頭的說明)。Co-Authored-By: Claude Opus 5 noreply@anthropic.com