Server Actions Reference
Server Actions("use server")在伺服器端執行,由 React 元件直接呼叫。
跨來源注意: Server Actions 無法跨來源呼叫。請在部署後的 app 同來源環境中操作,或用上方 API 端點做跨來源存取。
Result envelope
瑞成 PMS 的 Server Actions 多回傳一個 Result 物件。案件相關動作(actions/cases/_shared.ts)的實際型別是:
type Result = { error?: string } | { ok: true; caseId?: string }
// 有 error 即失敗(繁中訊息,直接顯示給使用者);成功不 redirect,由 modal 關閉並 revalidate少數動作會多回傳識別碼(例如 addReviewRound 的 roundId/roundNo、createSiteVisit 的 visitId、setOfficialDocReplied 的 repliedAt)。
所有寫入動作都會經 rcLog 寫入稽核日誌(toast 回饋「已寫入稽核日誌」)。
案件 Actions(actions/cases.ts)
依角色與
RC_PERM_MATRIX旗標守衛。進度 % 覆寫他人僅admin(技師) — 行政(operator)不可填進度。
createCase(input): Promise<Result> // 4 步精靈建檔
editCase(id, input): Promise<Result> // 新加入的負責人員同樣經 checkAssignees(E402)
setCaseHardDeadline(caseId, { hardDeadline, hardDeadlineKind }): Promise<Result> // E403 窄動作:只改硬期限
voidCase(id, reason): Promise<Result> // 軟刪除(作廢),必填原因,可復原
restoreCase(id): Promise<Result>
updateProgress(caseId, pct, note): Promise<Result> // 自填式,必填備註
completeNode(caseId, nodeId): Promise<Result> // 節點式
addPrereq / editPrereq / markPrereq / deletePrereq(...) // 前置資料(NAS 連結、備註 note ≤200 字,trim、空→null)
addSubcase / editSubcase / subStatusChange / deleteSubcase(...) // B2 母子追加案(不計進度 %)
addNode(caseId, input): Promise<Result> // 彈性混合式可新增節點
updateNodeContent(caseId, nodeId, input): Promise<Result> // 彈性混合式可改節點名稱/權重
setCaseContacts(caseId, contactIds): Promise<Result> // E360 選取此案業主窗口(可多位)案件窗口(E360): 案件與業主聯絡人以 M:N 表
case_contacts明確關聯(migration 0022; 對既有案件 backfill 其業主所有在職聯絡人,維持原顯示)。setCaseContacts傳入完整 contactId 清單、伺服器算差集同步;權限比照editCase(guard('createCase')+ 案件可見、未作廢);每個 id 須屬於案件業主——跨業主、重複、不存在一律拒絕,空陣列合法;稽核記 before/after 窗口姓名。createCase(input)另收選填contactIds(同規則,先驗證再寫入);「複製節點內容」不帶入來源窗口。 E358 在案件頁直接編輯共用聯絡人的入口已移除——聯絡人資料只在業主資料庫編輯;聯絡人離職不 自動移出案件,案件卡標「已離職」。
彈性混合式(B3)節點內容編輯(E352):
updateNodeContent僅限彈性混合式(B3)案件, 同addNode的授權邊界(can('editNodes')— 管理員)。可改節點名稱與/或權重;權重改動沿用 新增節點的「合計 ≤ 100%」驗證(排除自己後與其他節點合計)。已完成節點鎖定 —doneAt非 null 即不可再修改名稱/權重,視為定案的歷史紀錄(比照死線手動覆寫「已完成不可再複寫」的 先例)。UI 在案件詳情頁節點列表的編輯按鈕。
複製節點內容(E354 → E400): 沒有新 Server Action — 列表/詳情頁的「複製節點內容」按鈕(與 建立案件同一道
createCase權限閘)以?duplicate=<caseId>深連結開啟既有的 4 步建案精靈,只 預選來源案件的分類(B3 含子型)並帶入來源案件目前實際的節點(依ord:name/weight/ deliverable/memo);名稱預填「{原名稱}(複製)」,業主、窗口、負責人員、協力廠商、前置資料與 起訖日一律留白。送出時呼叫一般的createCase(input),另帶兩個選填欄位:
copyFromCaseId?: string | null— 來源案件 id。伺服器以canSeeCase確認來源對建立者可見 (找不到與看不到回同一訊息「找不到來源案件。」,不洩漏存在性;已作廢案件可作為來源),且type必須等於來源案件的分類。稽核after.copiedFrom記來源 id、metadata.detail註明複製來源。nodes?: { name; weight; deliverable?; memo? }[] | null— 複製的節點,僅節點式/彈性混合式分類, 必須同時帶copyFromCaseId。提供時取代分類樣板展開,仍以expandNodes依新起訖日按權重切分planStart/planEnd(不帶 doneAt/isCurrent/approval*/deadline* 等歷程欄位);nodeWeights被忽略。 伺服器在任何寫入前驗證:1–20 個節點、名稱 trim 非空、權重 0–100 整數且合計 100 (validateNodeSeed),再重新讀取來源案件節點比對(copiedNodesVerdict)——節點數、順序、 名稱、交付物、Memo 一律須相同;權重只有具overridePct(管理員)可不同,行政須與來源完全相同 (行政不決定進度數字)。任何不合法 → 回繁中{ error }、不建案。自填式/轉發紀錄案件沒有節點:複製只預選分類,送出時只帶
copyFromCaseId。未帶這兩個欄位的一般 新增行為不變。
CSV 下載(E398): 所有「匯出 CSV」按鈕經
lib/download-csv.ts的downloadCsv(),保證檔案開頭有 UTF-8 BOM(EF BB BF),Excel 才不會把中文開成亂碼;新增匯出功能請走同一個 helper,不要自己建 Blob。
案件列表 CSV(actions/cases/csv.ts,E379)
exportCasesCsv(ids?): Promise<{ csv; filename; count } | { error }> // 僅需登入;範圍=列表可見範圍 ∩ ids
previewCasesCsv(csvText): Promise<ImportPreview | { error }> // guard('createCase');只讀
importCasesCsv(csvText): Promise<{ created; skipped; errors; caseIds } | { error }> // guard('createCase');只新增
- 可見範圍: 匯出與列表頁共用
filterVisibleCases(lib/rc-visibility.ts)——Staff 只匯出被指派的 案件;ids(列表目前篩選結果)只會在伺服器端與可見集合取交集,傳入看不到的案號不會洩漏。- 匯入內核:
createCase的業務邏輯抽成createCaseCore(input, ctx, { id? })(actions/cases/_create-core.ts, 非"use server"),createCase與importCasesCsv共用 → 節點展開、指派、窗口、協力廠商、狀態、 稽核(建立案件/代建檔 + onBehalf)等價。匯入逐列rcLog,再由defineAction寫一筆「匯入案件 CSV」批次摘要。- 案號: 留白依開始日年度配號(
allocateCaseIds把既有案號、本批指定案號、本批已配號都算進去); 指定案號須符合RC-YYYY-NNN、不得已存在/檔內重複;寫入撞唯一鍵(併發)回報該列錯誤、不中斷整批。- 解析/驗證: 純函式
lib/cases-csv-utils.ts(沿用 E377lib/csv-import-utils.ts),上限 200 列; 狀態/進度欄忽略並警告。
外審、現場勘查與期限 Actions(v1.3.0,Phases 99–103)
外審輪次與現場勘查行程的授權界線(E403):案件可見且未作廢(
authorizeCaseVisible)後,需為被指派技師本人、createCase持有者(管理員+行政)或editNodes持有者——判定在lib/rc-case-actor-rules.ts(canLogReviewRound/canLogSiteVisit),案件詳情的按鈕與設定頁「權限對照表」用同一組函式;非指派者代操作時稽核標onBehalf。這兩類只登記日期事實、不影響進度 %;completeNode仍只限被指派技師、overridePct/editNodes仍只有管理員。全部走defineAction,每筆成功寫rcLog(含 before/after),所有update/delete先檢查 rows-affected 再寫稽核(Stop Rule 26)。成功回{ ok }(不 redirect),由呼叫端 modal 關閉並revalidateCase。
外審輪次(actions/cases/review-rounds.ts,E391,migration 0026)
addReviewRound(caseId, nodeId, input): Promise<Result> // reviewer 必填;submittedAt 必填;commentsAt / repliedAt / note 選填
editReviewRound(caseId, roundId, input): Promise<Result>
deleteReviewRound(caseId, roundId): Promise<Result>
- 資料表
review_rounds(case_id、node_id皆ON DELETE CASCADE);round_no依節點自動遞增(max + 1,nextRoundNo),(node_id, round_no)唯一鍵撞到(併發新增)回友善訊息。- 日期驗證 在
lib/rc-review-utils.ts的validateRoundDates:收到意見日不得早於送審日、回覆日須有意見日且不得早於它、不得晚於今天(getRcToday());DB 另有兩條CHECK(review_rounds_comments_after_submitted/review_rounds_replied_after_comments)作最後防線。- 耗時
roundTurnaround:審查單位耗時(送審 → 收到意見)與我方耗時(收到意見 → 回覆),日曆天;未結束的一段計到今天並標「進行中」。- 連動 目前節點的最新一輪「審查中」→ 卡關歸屬「等外部回覆」(
COURT_RULES新增一條,業主核定與關鍵前置仍優先);「已收到意見、我方未回覆」→ 被指派技師的待辦(類別「外審」)。不改燈號。- 節點被刪除時,輪次經 FK cascade 一併刪除;
deleteNode的稽核會記下連帶刪除的輪次筆數(E398)。
現場勘查行程(actions/cases/site-visits.ts,E392,migration 0027)
createSiteVisit(caseId, input): Promise<Result> // visitDate、kind、memberIds[≥1];timeSlot 與 startTime 二擇一
editSiteVisit(caseId, visitId, input): Promise<Result> // 成員整批取代(transaction)
setSiteVisitDone(caseId, visitId, done): Promise<Result> // 完成/改回未完成;狀態未變回錯誤
deleteSiteVisit(caseId, visitId): Promise<Result>
- 資料表
site_visits(kind: 現勘/巡查/鑽心取樣/會勘/其他;time_slot: 上午/下午/全日;start_timeHH:MM)與site_visit_members(複合主鍵(visit_id, user_id))。DBCHECK:site_visits_slot_xor_time(時段與指定時間恰好一個)、site_visits_start_time_format。- 參與人員 至少一位,且必須是在職(未停用)使用者(
loadActiveMembers);稽核快照含成員姓名,勘查類型寫成visitKind(避免與稽核顯示層「業主屬性 kind」撞名,E398)。- 讀取
lib/site-visits-data.ts:Staff 只看自己參與的行程(visibleVisits);儀表板「本週行程」=台北日期週一~週日(weekRange);今天與明天未完成的行程成為參與人員的待辦(類別「現勘」,siteVisitTodos)。純函式在lib/rc-site-visit-utils.ts。
公文回覆期限與案件硬期限(E390,migration 0025)
setOfficialDocReplied(docNo, replied): Promise<{ ok: true; docNo; repliedAt } | { error }> // actions/official-docs.ts
// createOfficialDoc / updateOfficialDoc 新增 direction(收文/發文)與 replyDue;
// createCase / editCase 新增 hardDeadline 與 hardDeadlineKind(法院期日/法定期限/主管機關限期/其他)
official_documents新增direction(預設「收文」)、reply_due、replied_at;只有收文有回覆期限,發文一律清空。setOfficialDocReplied的已回覆日=伺服器端getRcToday()(不收用戶端日期);權限=createCase持有者,或該公文所屬案件的被指派技師(待辦裡的「回覆公文」由技師本人勾掉)。cases.hard_deadline(YYYY-MM-DD)與hard_deadline_kind;DBCHECK cases_hard_deadline_kind_required:有日期則類型必填。editCase省略=不變更。伺服器端用normalizeHardDeadline先驗證,失敗不寫任何資料。setCaseHardDeadline(caseId, { hardDeadline, hardDeadlineKind })(actions/cases/case.ts,E403):只改這兩欄的窄動作,讓被指派技師也能設定/修改/清除自己案件的硬期限,而不放寬createCase旗標(editCase行為不變)。授權=authorizeCaseVisible(可見、未作廢)→createCase持有者或該案被指派技師(canSetHardDeadline);hardDeadline為 null/空字串=清除;值未變 → 成功、不寫入、不寫稽核;否則UPDATE檢查 rows-affected 後寫rcLog(動作「設定硬期限」/「清除硬期限」,before/after)。UI:案件詳情「硬期限與公文回覆」卡的「設定/修改硬期限」。- 燈號 兩者只能把燈號拉高不降低(回覆期限/硬期限 3/7 個業主工作日內 → 至少橙,已過 → 紅),且一律經
resolveAlertLevel(lib/rc-schedule-utils.ts)這個單一入口;儀表板、待辦、案件列表/詳情、業主列表與通知掃描因此一致。純函式lib/rc-deadline-utils.ts。通知掃描新增每日去重的「公文回覆期限」「硬期限」兩類通知;管理費(transfer)案件沒有 % 概念,但仍會收到這兩類期限通知。- 公文 CSV 新增「收發方向/回覆期限/已回覆日」三欄、案件 CSV 新增「硬期限/硬期限類型」兩欄,皆附在最後且選填,舊格式照常匯入。
儀表板、待辦與「今天」(E385–E389、E393)
這一批沒有新增 Server Action,全是讀取層與純函式:
| 模組 | 內容 |
|---|---|
lib/rc-today.ts | getRcToday() — 台北今天。必須在函式/請求內呼叫;模組層常數會凍結在伺服器啟動當天(舊缺陷,E385)。守門測試 app/frozen-today-guard.test.ts 會讓行首宣告 getRcToday() / new Date() / Date.now() 的檔案失敗。runNotificationScanCore(today = getRcToday()) 的預設值同樣每次呼叫求值。 |
lib/rc-dashboard-utils.ts | OVERDUE_TERMS(固定的逾期用語:合約已逾期/節點落後/逾期待辦/有逾期項目)、ballInCourt 與 COURT_RULES(卡關歸屬)、workloadRows(Admin 技師負荷)、rcUpcoming(即將到期含已逾期群組)、matchesTodoFilter(?filter=all|overdue|week)。 |
lib/rc-days-utils.ts | E393:nodeBehindDays / nextDue / daysBehindRankRows——吃節點陣列(日後並行節點把「目前節點」改成進行中集合時不必重寫);等業主核定的節點不算我方落後。 |
| 網址篩選 | 案件列表 ?alert=overdue|duesoon|overdue_any|attention 與 ?court=<歸屬>;數字必須與儀表板卡片相等(e2e 與整合測試把關)。待辦 ?filter= / ?member=<工牌>(管理員與行政生效、唯讀,canSuperviseTodos,E403)。 |
| 單位 | 畫面上所有「逾期 N 天/剩 N 天」是日曆天;燈號與通知閥值維持業主工作日。 |
業主 CRM Actions(actions/clients.ts)
createClient / updateClient / deleteClient(...) // 刪除限無任何案件綁定;createClient 可帶初始窗口(contactName/Title/Phone/Email),updateClient 不碰窗口
createContact({clientId,name,title?,phone?,email?}) // E363:同業主同名同電話拒絕(「窗口已存在」),回傳 contactId
updateContact / toggleContactResign / deleteContact(...)
addContact / editContact / setContactStatus / deleteContact(...) // 含離職/復職
exportClientsCsv(): Promise<Result> // E378 匯出 CSV(一列一位窗口)
previewClientsCsv(csvText): Promise<ImportPreview> // E378 只讀預覽(逐列 通過/警告/失敗),不寫 DB
importClientsCsv(csvText): Promise<ImportCommitResult & {unchanged}> // E378 伺服器重驗後部分匯入一般人員(
staff)對 CRM 唯讀。業主 CSV(E378): 純解析/比對在
lib/clients-csv-utils.ts(planClientsImport,零 DB),建在 E377lib/csv-import-utils.ts之上,UI 用共用的components/rc/csv-import-dialog.tsx。業主編號留白=新增(配發C##)、有填且存在=更新、不存在=該業主各列錯誤;同編號(或無編號時同名稱)的列合併為一家業主,以第一列為準。 窗口以(業主, 姓名)比對:新增或更新職稱/電話/Email/在職,永不刪除未列出的窗口、不換 id(case_contacts不受影響)。留白=維持原值,所以「匯出 → 不修改 → 匯入」零寫入、零稽核。每筆寫入各一筆 rcLog(新增業主/ 修改業主資料/新增聯絡窗口/修改聯絡窗口)+一筆「匯入業主 CSV」摘要;最多 200 列;權限同編輯業主(createCase)。
協力廠商 Actions(actions/partners.ts)
協力廠商(甲方/上包/下包/營造廠/事務所,自由標籤)為 Phase 79(E332)新增的獨立登錄; CRUD 守衛同案件建立(
guard('createCase')— 管理員+行政),刪除需guard('deleteData')且有任何案件關聯時會擋下。案件 ↔ 協力廠商的連結由actions/cases/partners.ts的updateCasePartners(caseId, partnerIds)整批同步(伺服器端計算增/刪差集)。
createPartner / updatePartner / deletePartner(...)
exportPartnersCsv(): Promise<Result> // 匯出總表 CSV
importPartnersCsv(rows): Promise<Result> // 批次新增/更新CSV 匯出/匯入(E351,比照 E330 公文總表的既有模式): 比對鍵為廠商編號 (
partnerId)——留白代表新增(由伺服器端配發新編號P01…),填入既有編號代表更新。 多值欄位(標籤、關聯案件)在 CSV 內以全形頓號「、」分隔。關聯案件欄為整批覆寫語意 (full-sync):重新匯入時,若某案件從該廠商「關聯案件」欄被移除,匯入會一併刪除該筆關聯 ——先匯出、編輯、再匯入才能正確還原成 CSV 內容本身。與公文匯入不同的是,若一筆資料列的 「關聯案件」欄中有單一參照解析不到案件,只標記該筆參照為錯誤,不讓整列匯入失敗(公文 的案號欄只有單一值,無法解析即整列失敗;協力廠商的關聯案件可以是 0~多個,允許部分成功)。 UI 在「協力廠商」列表頁的「匯出 CSV」/「匯入 CSV」按鈕;純解析/驗證邏輯在lib/partners-csv-utils.ts(零 DB import,可單元測試),upsert + 關聯同步在 action 層。
待辦與系統設定
// actions/todos.ts — 我的待辦(節點 / 前置 / 進度任務)
// actions/categories.ts — 自訂分類 CRUD(內建六大分類不可刪)
// actions/calendars.ts — 業主工作日曆 CRUD(預設不可刪、有業主套用不可刪)帳號管理 Actions(actions/accounts.ts)
全部守衛
manageUsers旗標(從 DB re-read live role)。停用為軟刪除(可復原),不可停用自己;被停用的帳號下一次請求即失去權限並被登出(getLiveRole與jwtcallback 都會檢查,E405);resetPassword/disableUser/CSV 匯入更新列(有填密碼)/自己的changePassword會遞增users.session_version,使該帳號所有既有登入失效(E406),所以重新啟用後要重新登入。 授予管理員角色、更動既有管理員帳號僅管理員(見下方「帳號權限收斂」)。
createUser({ name, title?, roles?, role?, email? }): Promise<Result> // roles ≥1;省略 = [role ?? "staff"];含 admin 僅管理員
updateUser(badge, { name, title?, roles?, role?, email? }): Promise<Result> // roles 或 role 擇一必填;降級前須先改派
disableUser(badge): Promise<Result> // 軟刪除(停用),可復原;不可停用自己/最後一位管理員
enableUser(badge): Promise<Result>
resetPassword(badge, newPassword): Promise<Result> // 管理員/行政重設密碼,bcrypt 雜湊;管理員帳號僅管理員
importUsersCsv(csvText) / exportUsersCsv() // 角色欄可多值,以「、」分隔(operator、staff)帳號權限收斂(E404,
lib/rc-account-rules.ts,零 DB 純函式): 授予管理員角色、以及更動既有管理員帳號, 一律須由管理員執行——否則持有manageUsers的行政可把自己升為管理員,或重設管理員密碼後以其身分登入。 所有寫入帳號的路徑共用accountWriteDenial(actorRole, { target?, nextRoles? })(canManageAccount+canGrantAdmin),actorRole一律是defineAction由 DB 重讀的有效角色(ctx.role):
路徑 判定 createUsernextRoles含 admin → 僅管理員updateUser交易內 FOR UPDATE鎖住目標列後判定:目標含 admin → 僅管理員;nextRoles含 admin → 僅管理員disableUser/enableUser/resetPassword交易內鎖住目標列後判定:目標含 admin(含已停用的管理員)→ 僅管理員 importUsersCsv→importUsersFromRows(lib/users-import-core.ts)逐列:新增列含 admin、更新列目標含 admin 或新角色含 admin → 該列錯誤,其餘列照常;判定依據是呼叫端傳入的 actor.role(App=ctx.role;名冊腳本=SYSTEM_ACTOR(admin),不受限;null→ fail-closed,全部列拒絕)拒絕訊息「管理員帳號與管理員角色需由管理員操作。」;拒絕一律不寫入、不寫稽核。畫面讀同一組函式:帳號管理對話框 用
grantableRoles決定能不能看到「管理員」選項、管理員帳號列對行政唯讀(canManageAccount,提示「需由管理員操作」); 權限對照表「指派技師專屬與其他規則」區的「授予管理員/更動管理員帳號」列由canAdministerAdmins(lib/rc-case-actor-rules.ts)推導。角色降級前須先改派:
roleChangeNeedsReassignment(before, after)=canBeAssigned由真變假(例 [operator, staff] → [operator]、[admin] → [operator])。此時updateUser與 CSV 更新列(App + 核心,含 SYSTEM)在同一交易內以openAssignmentsForUser(lib/users-import-core.ts,case_assignees ⋈ cases,isOpenCaseStatus=非 已結案/已作廢) 查該帳號的未結案指派;有則拒絕,訊息由reassignmentBlockedMessage產生(每案一行「・案號 案名」,至多 10 件, 其餘「…另 N 件」)。已結案/已作廢案件的歷史指派不擋;與最後一位管理員防呆並存。
多角色(E402,migration 0029): 帳號持有角色集合
users.roles(≥1);users.role為有效角色=集合中最高者 (admin > operator > staff,在 10 個旗標上嚴格包含),所有 guard/can()/getLiveRole仍只讀role,不需改動。 Zod(createUserSchema/updateUserSchema/importUserRowSchema)把輸入正規化成{ roles, role }(lib/rc-roles.tsnormalizeRoles/highestRole;舊的單一role輸入=[role],兩者皆給以roles為準),action 在同一個 INSERT/UPDATE 寫兩欄;DBCHECK users_roles_consistent為最後防線。稽核記角色集合 before/after (metadata.detail例「角色 行政人員 → 行政人員、一般人員」),actorRole仍是操作者的有效角色。至少保留一位未停用的管理員:
updateUser、disableUser與 CSV 匯入的更新列,都在交易內先SELECT … FOR UPDATE鎖住所有未停用管理員列(lockActiveAdmins,lib/users-import-core.ts)再判斷removesLastAdmin——會讓系統沒有任何未停用管理員的異動一律拒絕(CSV 為該列錯誤)。併發的兩筆降權會在鎖上排隊, 後到者重讀後被拒。可被指派由角色集合推導:
canBeAssigned(roles)=含 staff 或 admin。建案(createCaseCore)、editCase、 案件 CSV 匯入共用checkAssignees(actions/cases/_shared.ts→ 純函式validateAssignees):每位新加入的 負責人員須存在、未停用、角色集合可被指派,只有行政角色者回「「姓名」僅有行政人員角色,不可被指派為負責人員。」;editCase會保留案件上既有的指派(keep,例如已離職者)不重驗。指派選單、技師負荷、督導成員共用isAssignableUser。
resetPassword(E302): 由adminResetPasswordSchema(lib/validations/engineering.ts)驗證工牌 + 密碼強度,以hashPassword(bcrypt)寫入usersTable.passwordHash,並寫一筆「重設密碼」rcLog稽核 — 稽核列不記錄任何密碼明文或雜湊值。因工牌帳號的 email 為合成@rc.local,/forgot-password郵件重設無法送達,管理員重設為實務上的密碼救援路徑。
稽核與通知
// actions/admin.ts — 稽核日誌讀取(僅 admin,可篩選 + 匯出 CSV,永久不可刪)
// actions/notifications.ts — 系統內通知鈴鐺(不寄 Email)
// actions/notifications-scan.ts — 每小時整點燈號掃描(in-process node-cron,見 instrumentation.ts;NOTIFICATION_SCAN_CRON 可覆寫)
// 掃描核心 lib/notifications-scan-core.ts:資料只讀一次、與儀表板同一套 resolveAlertLevel;v1.3.0 新增公文回覆期限/硬期限兩類通知