Specification
nia-shift 仕様書
シフト管理Webアプリ「nia-shift(Pegasasu-Shift)」のCloudflare版(現行本番)についての詳細仕様書。実際のソースコード(cloudflare-app/)を精査し、動作を正確に記述している。
0概要
nia-shiftは、複数の販路(営業チーム/拠点)を掛け持ちするメンバーのシフトを管理し、翌日・当日の稼働メンバーをLINE/Chatworkへ自動通知するWebアプリ。元はGoogle Apps Script(GAS) + Google Sheetsで構築されていたが、現在はCloudflare Pages + D1(SQLite互換)へ完全移植されている(§10 開発の経緯参照)。
- メンバーは「販路(SalesChannels)」に所属し、日付ごとに「稼働 / 早退 / 休み / 連絡不可」のいずれかのステータスでシフトを登録する(早退も稼働と同じく開始・終了時刻を持つ)
- 管理者・一般ユーザーの権限に加えて、6段階の「役職(New Beginner〜Owner)」で表示・操作範囲が変わる
- 15分おきのバッチ(notify-worker)が、販路ごと・全体まとめの両方でLINE/Chatworkに自動通知を送る
- 全操作(作成・更新・削除)は
ShiftHistoryテーブルに変更履歴として記録される
1技術スタック
| 領域 | 技術 | 備考 |
|---|---|---|
| フロントエンド | 素のHTML + CSS + JavaScript(public/index.html 1ファイル) | フレームワーク不使用。約4,360行のSPA |
| バックエンドAPI | Cloudflare Pages Functions | functions/api.js。/apiへの単一POSTエンドポイント(RPC方式) |
| データベース | Cloudflare D1(SQLite互換) | 9テーブル。wrangler.tomlでDBとしてバインド |
| 定期実行バッチ | 独立したCloudflare Worker(notify-worker/) | Cron Trigger(*/15 * * * *)。Pages Functions単体はCronを扱えないため分離 |
| 認証 | Web Crypto API(HMAC-SHA256 / SHA-256) | Node標準crypto非使用。Workers環境向けに自前実装(functions/lib/auth.js) |
| 外部通知連携 | LINE Messaging API / Chatwork API | トークンはCloudflare環境変数で管理 |
| 祝日データ | Googleカレンダー公開iCalフィード | 都度fetch・パースのみ(DB保存なし) |
| ホームアイコン起動 | Firebase Hosting(別プロジェクト) | standalone起動時に本体URLへ自動転送するだけの薄いページ |
2アーキテクチャ
リクエストは大きく2系統。(A) ブラウザ→Pages Functions→D1 のオンデマンドAPI呼び出しと、(B) notify-worker→D1/外部API のバッチ通知。両者は同じD1データベース(nia-shift-db)を共有するが、デプロイ単位(Pages / Worker)は別々。
(A) オンデマンドAPIフロー
onRequestPost
{ ok: true, result } か { ok: false, error } のJSON。HTTPステータスは基本200固定(JSONパース失敗時のみ400)
(B) 通知バッチフロー(15分おき)
同時に期限切れセッション(Sessionsテーブル)の削除も毎回実行される。
リクエスト方式の特徴
- RESTではなくRPC方式:
POST /apiのボディに{ fn: "api_xxx", args: [...] }を渡し、functions/api.js内の同名関数をそのまま呼び出す(GAS版のWebApp.jsを踏襲した設計) - 認証情報(teamToken / セッションtoken)は毎回の
argsの先頭付近で明示的に渡す方式(Cookie不使用)。ブラウザ側はlocalStorageに保存して都度読み出す
3ディレクトリ構成
cloudflare-app/ 配下(現行本番のソース一式。GitHubリポジトリのルートと一致)。
migrations/9999_migrate_data.sql(実データ生成物)と db-dump.html / infra-map.html はリポジトリのルート(cloudflare-app/直下)にローカルでのみ存在し、.gitignoreでGit管理対象外。理由は§12を参照。
4データモデル
D1(SQLite)上の9テーブル。基本の8テーブルはmigrations/0001_init.sql、既読管理のUserHistoryReadEntriesはmigrations/0005_shift_change_read_entries.sqlで追加された。列名は日本語(GAS版のSheetsヘッダーをそのまま踏襲)。
4.1 Users(メンバー)
| 列 | 型 | 内容 |
|---|---|---|
UserID | TEXT PK | USR-<timestamp>-<random>形式で自動採番 |
氏名 / よみがな | TEXT | 表示名・ふりがな(あいまい検索・五十音ソートに使用) |
所属販路ID | TEXT | メインで所属する販路(SalesChannels.販路ID) |
権限 | TEXT | 管理者 / 一般の2値 |
役職 | TEXT | New Beginner / Beginner / New Leader / Leader / Manager / Owner の6段階(§5) |
直属リーダーUserID | TEXT | 樹形図表示・並び替えの親子関係に使用 |
優先順位 | INTEGER | 同グループ内の任意の表示順(未設定可) |
自分を先頭表示 | INTEGER(0/1) | グリッドで自分の行を常に先頭に固定表示するか |
PasswordHash / PasswordSalt | TEXT | SHA-256(salt + password)。ソルトはcrypto.randomUUID() |
メールアドレス | TEXT | 現行フローでは未使用(GAS版のGoogleアカウント認証の名残。常に空文字) |
有効 | INTEGER(0/1) | ソフトデリートフラグ(migrations/0002_add_user_active_flag.sqlで追加、デフォルト1)。UserRepository.getAll()/findById()は既定で有効=1のみ返すため、削除後はログイン・グリッド・階層判定など既存の全呼び出し元から自動的に消える。管理者/Leader以上向けの削除済み一覧・復元はgetAllIncludingInactive()/findByIdIncludingInactive()経由 |
通知ダイレクト有効 / セカンド / サード / フォース有効 | INTEGER(0/1) | 「変更履歴」ヘッダーバッジの通知範囲設定(migrations/0004_shift_change_notify_levels.sqlで追加。マイプロフィールから本人が設定)。自分から見て何階層下の配下メンバーの変更まで受け取るか(ダイレクト=1階層下…フォース=4階層下)をON/OFFする。デフォルトはダイレクトのみON。LINE/Chatworkなど外部送信ではなくアプリ内のみ(§5.5) |
4.2 SalesChannels(販路マスタ)
| 列 | 内容 |
|---|---|
販路ID | PK。CH-<timestamp>-<random> |
販路名 / 表示順 | 一覧・グリッドでの並び順 |
商材 / 備考① / 備考② / クライアント / 場所 | すべて自由記述の付帯情報 |
4.3 Shifts / ShiftHistory
| 列 | 内容 |
|---|---|
ShiftID | PK。SFT-<timestamp>-<random> |
日付 | yyyy-MM-ddのプレーン文字列(タイムゾーン変換はJS側で行う) |
UserID / 販路ID | 誰の・どの販路のシフトか。(UserID, 日付)にUNIQUE制約(idx_shifts_user_date_unique、migrations/0007_shifts_unique_user_date.sql)があり、1人1日1行に制限される(§12) |
ステータス | 稼働 / 休み / 連絡不可 |
開始時刻 / 終了時刻 | ステータス=稼働の時のみ値を持つ(他の値に変更すると自動でクリアされる。_normalizePatch) |
備考 | 自由記入。連絡不可の理由もここに統一 |
更新日時 / 更新者UserID | 保存のたびに自動更新 |
| ShiftHistory(変更履歴。Shiftsの操作ごとに自動追記) | |
|---|---|
HistoryID | PK。HIS-<timestamp>-<random> |
ShiftID / 変更日時 / 変更者UserID | 対象・タイミング・実行者 |
変更項目 | 列名、または新規作成 / 削除 |
変更前 / 変更後 | 作成時は変更前が空、削除時は変更後が空でJSON文字列を格納 |
update/updateKnown)は、変更前後を列ごとに文字列比較し、差分があった列だけ1行ずつShiftHistoryに追記する。新規作成・削除は1行にまとめてJSON化して記録する。変更日時にはidx_history_changed_atインデックス(migrations/0006_history_and_logs_indexes.sql)があり、変更履歴フィードの並び替えと2ヶ月保持ジョブの絞り込みで使われる。4.4 Destinations(通知送信先)
販路1つにつき1行。「翌日通知」と「当日通知」それぞれ独立した送信先・有効フラグ・通知時刻を持つ(列名の先頭に当日が付くかどうかで区別)。
| 列(翌日分) | 列(当日分) | 内容 |
|---|---|---|
LINEグループID | 当日LINEグループID | LINE Messaging APIの送信先グループID |
ChatworkルームID | 当日ChatworkルームID | Chatworkの送信先ルームID |
有効フラグ | 当日有効フラグ | 0/1。無効な販路は通知バッチでスキップ |
通知時刻 / 通知分 | 当日通知時刻 / 当日通知分 | 0〜23時・0/15/30/45分の15分刻みで指定。未設定時は18:00がデフォルト |
4.5 NotificationLogs / Settings / Sessions / UserHistoryReadEntries
| テーブル | 役割 |
|---|---|
NotificationLogs | 全通知送信の成否ログ(日時・販路ID・LINE/Chatwork種別・成否・本文・エラー内容)。販路ID='ALL'は全体まとめ通知。DB容量節約のため直近2ヶ月分のみ保持(NotificationService.pruneLogsOlderThan、§8)。(成否, 送信日時)に複合インデックスidx_notification_logs_status_timeがあり、直近24時間の失敗件数チェック(api_getNotificationHealth)で使われる |
Settings | Key/Value型の汎用設定テーブル。オーナーUserID・全体まとめ通知の設定(送信先・時刻・有効フラグ、翌日/当日分)を保持 |
Sessions | 個人ログインセッション(Token PK、有効期限1週間)。notify-worker実行のたびに期限切れ行を自動削除 |
UserHistoryReadEntries | ヘッダー「変更履歴」画面の既読管理(migrations/0005_shift_change_read_entries.sql)。(UserID, HistoryID)の組ごとに「既読」列(0/1)を持つ。既読にしても行は削除しない。チェックボックスでの個別既読、または「すべて既読にする」で一括既読(§5.5) |
5認証・権限モデル
nia-shiftには独立した3層の権限チェックがある。(1) チーム全体のゲート、(2) 個人ログイン(セッション)、(3) 役職・特定アカウントによる操作制限。
5.1 チームゲート(teamToken)
- 全メンバー共通の「チームパスワード」(Cloudflare環境変数
ACCESS_PASSWORD)をapi_verifyTeamAccessに渡して検証 - 成功すると
issueTeamToken()がbase64url(expiresAt.HMAC-SHA256(expiresAt, TEAM_TOKEN_SECRET))形式の署名付きトークンを発行(有効期限6時間) - ブラウザは
localStorage["niaShiftTeamToken"]に保存し、以降の閲覧系API呼び出し全てに付与する - サーバー側は
verifyTeamToken()で署名のタイミングセーフ比較と有効期限を検証する(functions/lib/auth.js) - 他チーム(第三者)からの閲覧を防ぐための「入口の鍵」であり、個人の識別はしない
5.2 個人ログイン(セッション)
- 名前を選択 + 個人パスワードで
api_login→SHA-256(salt + password)をPasswordHashと比較 - 一致すると
Sessionsテーブルにトークンを発行(有効期限1週間)。localStorageに保存 - 名前とパスワードは両方空欄でも入場可能(閲覧のみモード)。その場合
state.tokenはnullのままで、編集系APIは全て拒否される - 新規メンバーは「新規登録」(
api_register)からセルフサインアップ可能。ただし管理者ログイン中でないと実行できない(_assertAdmin)
5.3 役職(JOB_RANK)による操作範囲
- Leader未満(New Beginner / Beginner / New Leader、および未ログインの閲覧のみユーザー): New Beginnerランクのメンバーのシフト・アカウントがグリッド/ユーザー一覧から完全に非表示になる(
_visibleUsersFor)。New Beginnerが早期離脱した際にBeginner・New Leaderへ影響が及ばないようにする設計。Leader以上には通常通り表示される - Leader以上: 販路の新規作成・編集が可能(
_assertLeaderOrAbove)。それ未満は閲覧のみ - Leader以上: ハンバーガーメニューの「参考」セクション(運用基盤マップ・仕様書・ソースツリー)が表示される。これはメニュー項目の表示/非表示のみのクライアント側判定で、各ページ自体にサーバー側の認可は無い
- Leader以上(組織スコープ): 「シフト変更」「シフト登録」で、管理者以外でも自分の配下メンバー(
直属リーダーUserIDを辿って到達できる、直属・間接問わない全員)のシフトを編集・登録できる。対象は_isDescendantOfでサーバー側も検証するため、フロントの対象メンバー一覧を改ざんしても配下以外は保存できない。管理者は従来通り全メンバーが対象 - Leader以上(組織スコープ): 「ユーザー管理」モーダルも同様のスコープで利用できる(
_assertCanAccessUserManagement/_assertCanManageUser)。管理者以外は自分自身+自分の配下のメンバーのみ編集・削除・復元でき、権限(管理者/一般)欄は編集不可(選択も不可)。新規ユーザー追加・チームパスワード変更・オーナー設定は引き続き管理者限定でモーダル内非表示 - ユーザー削除はソフトデリート:
api_deleteUserは行を消さず有効=0に更新するのみ。削除後はUserRepository.getAll()/findById()のデフォルトフィルタにより、ログイン・グリッド・階層判定など既存の全処理から自動的に見えなくなる。「削除済みメンバー」欄(api_getDeletedUsersAdmin)からapi_restoreUserで復元可能(同じ組織スコープ判定) - 管理者: ハンバーガーメニュー最下部の「外部アプリ」セクション(姉妹プロジェクトnia-reminderへの外部リンク)が表示される。こちらもクライアント側のメニュー表示/非表示のみ
- 役職は権限(管理者/一般)とは独立した軸。一般ユーザーでもLeaderなら販路編集ができる
5.4 権限マトリクス
| 操作 | 必要な条件 |
|---|---|
| シフト閲覧 | teamTokenのみ(個人ログイン不要) |
| 自分のシフト編集 | 個人ログイン必須 |
| 他人のシフト編集 | 権限=管理者、または役職=Leader以上(自分の配下のみ。§5.3) |
| メンバー新規登録 | 権限=管理者のみ |
| メンバー情報の編集 | 権限=管理者、または役職=Leader以上(自分自身+自分の配下のみ。権限欄の変更のみ管理者限定) |
| 販路の新規作成・編集 | 役職=Leader以上 |
| 販路の削除 | オーナー(Settings.OWNER_USER_ID)、または下記「特定アカウント」 |
| メンバーの削除(ソフトデリート)・復元 | 権限=管理者、または役職=Leader以上(自分の配下のみ) |
| 通知送信先(Destinations)の管理 | 下記「特定アカウント」のみ(氏名の完全一致でハードコード) |
| DB生データ閲覧(マスク付き) | 権限=管理者 |
| 「変更履歴」ヘッダーバッジの閲覧 | 役職=Leader以上(§5.5) |
functions/api.js内のMEMBER_DELETE_OWNER_NAME定数(特定1名の氏名の完全一致)でハードコードされている。実際の氏名はソースコード(functions/api.js冒頭)を直接確認のこと。このアカウントを削除・改名すると、誰もこれらの操作を行えなくなる。将来的には氏名ではなく役職・フラグベースのロール化を推奨。
5.5 「変更履歴」ヘッダーバッジ(Leader以上限定)
リーダー(マネージャー・オーナー含む)本人が、マイプロフィール画面で「自分から見て何階層下の配下メンバーの変更を受け取るか」をダイレクト(1階層下)/セカンド(2階層下)/サード(3階層下)/フォース(4階層下)の4段階で個別にON/OFFできる(デフォルトはダイレクトのみON)。 対象範囲内のメンバーが自分自身のアカウントでシフトを変更(新規作成・編集・削除)すると、そのリーダーのヘッダー「変更履歴」ボタンに赤丸バッジで未読件数が表示される。 他人が代理で変更した場合は通知対象に含まれない。LINE/Chatworkなど外部への送信は行わず、アプリ内のみで完結する。
- 「変更履歴」ボタン・バッジ自体が役職=Leader以上のログインユーザーにしか表示されない(
canManageChannels()によるクライアント側判定)。サーバー側もapi_getShiftChangeFeed/api_getShiftChangeUnreadCountで同条件を検証する - 階層深さの判定は
直属リーダーUserIDを辿って算出する(_depthFromLeader)。5階層以上下のメンバーは通知対象外 - 既読管理は
UserHistoryReadEntriesテーブルでエントリ(HistoryID)ごとに行う。変更履歴画面の各行にチェックボックスがあり、チェックするとapi_markShiftChangeEntryReadでそのエントリのみ既読になる。「すべて既読にする」(api_markShiftChangeHistoryRead)は表示中の全件を一括既読にする。既読にしても行は削除しない - 変更履歴画面では日時/対象メンバー順にソート可能(
change-history-sort) - シフトが既に削除済みの場合でも、
ShiftHistoryの「新規作成」時の変更後、または「削除」時の変更前に保存されたJSONから対象メンバーを復元して表示する(ShiftRepository.getChangeFeedForOwners) ShiftHistoryはDB容量節約のため直近2ヶ月分のみ保持する。notify-workerの毎朝6:00ジョブ(ShiftRepository.pruneHistoryOlderThan)が2ヶ月より古い行と対応する既読管理行を削除する(§8)
6API リファレンス
全エンドポイントはPOST /api に { "fn": "関数名", "args": [...] } を送る単一のRPC方式(functions/api.js)。凡例: 公開=認証不要、チーム=teamToken必須、個人=セッションtoken必須、管理者=権限チェック、特定=特定アカウントのハードコード判定。
| 関数 | 認可 | 概要 |
|---|---|---|
api_verifyTeamAccess | 公開 | チームパスワードを検証しteamTokenを発行 |
api_getLoginableUserNames | 公開 | ログイン画面の名前コンボボックス用に、全ユーザーの氏名/よみがなだけ返す |
api_login | チーム | 個人パスワード照合 → セッション発行 |
api_register | 管理者 | 新規メンバーのセルフ登録相当(実際は管理者が代理登録)。氏名+よみがなが既存メンバー(削除済み含む)と完全一致する場合はエラー(_validateNoDuplicateName) |
api_updateProfile | 個人 | 自分の氏名・よみがな・所属販路・パスワードを更新 |
api_updateSelfTopPreference | 個人 | グリッドで自分の行を先頭固定表示するか切替 |
api_getBootstrap | チーム | 初期表示用の販路一覧・ユーザー一覧をまとめて取得 |
api_getShifts | チーム | 指定期間のシフトを取得(閲覧可能ユーザーのみにフィルタ) |
api_getHolidays | チーム | Google祝日iCalを都度取得・パースして返す(DB非保存) |
api_getShiftHistory | チーム | 1シフトの変更履歴一覧(新しい順) |
api_getShiftChangeFeed | Leader以上 | ヘッダー「変更履歴」画面用。マイプロフィールの通知範囲設定に応じた階層の配下メンバーが、自分自身のアカウントで行ったシフト変更を新しい順に返す(§5.5) |
api_getShiftChangeUnreadCount | Leader以上 | 「変更履歴」ヘッダーバッジ用の未読件数 |
api_markShiftChangeHistoryRead | 個人 | 「変更履歴」画面の一括既読(表示中の全エントリを既読にする) |
api_markShiftChangeEntryRead | 個人 | 「変更履歴」画面のチェックボックスによる1エントリ単位の既読 |
api_saveShift | 個人 | 1件のシフト作成/更新(他人分は管理者、またはLeader以上が自分の配下に対してのみ) |
api_bulkApplyShifts | 個人 | グリッドで複数セル選択→同一内容を一括反映(他人分は管理者、またはLeader以上が自分の配下に対してのみ) |
api_registerMonth | 個人 | 「月まとめて登録」モーダルからの一括新規登録(他人分は管理者、またはLeader以上が自分の配下に対してのみ) |
api_deleteShift | 個人 | 1件のシフト削除(履歴に記録、他人分は管理者、またはLeader以上が自分の配下に対してのみ) |
api_getStorageUsage | チーム | D1ストレージ使用率の表示用(notify-workerが毎朝保存した値を読むだけ) |
api_getLineQuotaUsage | チーム | LINE月間メッセージ送信数の表示用(同上) |
api_getNotificationHealth | チーム | 直近24時間のLINE/Chatwork送信失敗件数と最新エラー内容 |
api_getUsersAdmin | Leader以上(組織スコープ) | ユーザー管理モーダル用の詳細一覧(パスワード設定有無を含む)。管理者は全員、Leader以上(非管理者)は自分自身+自分の配下のみ返す |
api_getDeletedUsersAdmin | Leader以上(組織スコープ) | 削除済み(有効=0)メンバーの一覧。復元UI用。スコープはapi_getUsersAdminと同じ |
api_restoreUser | Leader以上(組織スコープ) | ソフトデリートしたメンバーを有効=1に戻す(§5のスコープ判定に準拠) |
api_createUser | 管理者 | 管理者画面からのメンバー新規作成(新規作成のみ引き続き管理者限定)。氏名+よみがなが既存メンバー(削除済み含む)と完全一致する場合はエラー(_validateNoDuplicateName) |
api_updateUser | Leader以上(組織スコープ) | メンバー情報の更新(最後の管理者の権限降格は禁止)。管理者は全員、Leader以上(非管理者)は自分自身+自分の配下のみ、かつ権限(管理者/一般)の変更は管理者のみ |
api_updateAccessPassword | — | 常にエラーを返すダミー実装(§11参照) |
api_getChannelsAdmin | チーム | 販路管理モーダル用の全項目付き一覧 |
api_createChannel | Leader以上 | 販路の新規作成 |
api_updateChannel | Leader以上 | 販路情報の更新 |
api_deleteChannel | 特定 | 販路削除。オーナー、または特定アカウントのみ |
api_claimChannelOwner | 管理者 | ログイン中の管理者を「オーナー」としてSettingsに登録(未設定時のみ) |
api_deleteUser | Leader以上(組織スコープ) | メンバーのソフトデリート(有効=0に更新、行自体は削除しない)。管理者は全員、Leader以上(非管理者)は自分の配下のみ対象。api_restoreUserで復元可能 |
api_getDestinationsAdmin | 特定 | 通知送信先一覧(特定アカウント専用) |
api_updateDestination | 特定 | 1販路分の通知送信先・時刻を更新 |
api_sendAllNotificationsNow | 特定 | スケジュールを無視して今すぐ全販路に通知送信(手動トリガー) |
api_getBulkNotificationSettings | 特定 | 「全体まとめ通知」設定の取得(翌日/当日分) |
api_updateBulkNotificationSettings | 特定 | 「全体まとめ通知」設定の更新 |
api_logout | 公開 | 渡されたセッションtokenをSessionsから削除 |
api_getRawDump | 管理者 | 1テーブル全行取得。PasswordHash/PasswordSaltは••••••••に自動マスク |
api_getRawDumpTables | 管理者 | ダンプ可能なテーブル名一覧(許可リスト。SQLインジェクション対策) |
{ok:true, result} / {ok:false, error}。errorにはそのままError.message(日本語の文言)が入るため、フロント側はそれをそのままエラーメッセージとして表示できる。
7フロントエンド機能(public/index.html)
単一HTMLファイルのSPA。ビルド工程なし、フレームワーク不使用で、stateというグローバルオブジェクトにアプリ全体の状態を保持する素朴な設計。
7.1 入口・認証系
- チームゲート画面: 起動時に
localStorageのteamTokenが無効/期限切れなら必ず表示。パスワード + (任意で)個人名・個人パスワードを同時入力可能 - 新規登録モーダル: 管理者ログイン中のみ、氏名・よみがな・所属販路・役職・直属リーダー・初期パスワードを入力してメンバー追加
- ログインモーダル: 閲覧のみモードから編集操作をしようとした際に割り込み表示(
requireLogin) - プロフィール編集モーダル: 自分の氏名・よみがな・所属販路・パスワード変更。役職=Leader以上のログインユーザーには「変更履歴」の通知範囲設定(ダイレクト/セカンド/サード/フォースのON/OFF)も表示される(§5.5)
7.2 シフトグリッド(メイン画面)
- 行=メンバー、列=日付のカレンダーグリッド。週表示/月表示を切替可能(
state.viewMode) - 並び替えは5種類: 樹形図順(デフォルト)(直属リーダーの階層)・販路順・名前順(よみがな)・優先順位順・役職順。各々昇順/降順切替あり
- 「常に自分を一番上に表示」チェックが有効かつ樹形図順の場合、自分の行だけでなく自分の配下(直属・間接問わず)も含めたブロックごと先頭へ移動する(配下側の樹形図の並び自体は保持)。樹形図以外の並び替えでは従来通り自分の行だけが先頭へ移動する
- セルの色分け: 稼働=通常色+時間表示、早退=オレンジ背景+時間表示+「早退」バッジ、休み=グレー「休」、連絡不可=赤背景「連絡不可」(備考があればツールチップ表示)
- マウスホバー時、その行(メンバー)全体をうっすら紫色でハイライトする(
box-shadowの重ね塗りのためCSSのみで実装、既存のセル色分けは維持される) - 編集可能マーク: ログイン中の本人がシフト変更・登録できるメンバー(自分自身 + 管理者なら全員 + Leader以上なら自分の配下)の名前に赤色の鉛筆アイコンを表示する(
selectableTargetUsersを流用)。閲覧のみ(未ログイン)では一切表示されない - 土日は列の背景色を変えて視認性を確保(
col-sun/col-sat) - 祝日はAPI経由で取得し、該当日の列にマーキング
- 役職の可視化(Leader以上限定): 閲覧者の役職がLeader以上の場合のみ、メンバー名セルの下に役職名を表示し、セル自体も役職ごとに色分けする(
canViewJobRank)。Leader未満・未ログインの閲覧者にはどちらも表示されない
7.3 セル選択・一括編集
- 複数セル選択モード: マウスドラッグで矩形選択 → 「一括反映モーダル」で同一のステータス/時間帯/備考をまとめて適用(
api_bulkApplyShifts) - 詳細パネル(1セル): クリックで開き、「編集」タブと「変更履歴」タブを持つ。履歴タブは開いたときに遅延ロード。編集可否は
selectableTargetUsersと同じ判定(自分自身・管理者は全員・Leader以上は自分の配下)で、対象外の場合は入力欄が無効化され保存/削除ボタンも非表示になる
7.4 自分のシフトをまとめて操作する2つの画面
| 画面 | 用途 | API |
|---|---|---|
| シフト登録モーダル(月まとめて登録) | 対象月を前月/翌月ボタンで自由に切り替えて新規登録(シフト変更モーダルと同じ月送りUI)。管理者は全メンバー、Leader以上は自分の配下メンバーを対象に切替可能 | api_registerMonth |
| シフト変更モーダル | 既存の1ヶ月分を行ごとにその場編集(管理者は全メンバー、Leader以上は自分の配下メンバーを対象に切替可能) | api_saveShift(行単位) |
7.5 管理者向け画面
- ユーザー管理モーダル: メンバー一覧・追加・編集・削除(ソフトデリート)・削除済みメンバーの復元、チームパスワード変更UI(※非機能。§11)、販路削除オーナーの自認定ボタン。管理者は全機能、Leader以上(非管理者)は自分自身+自分の配下のメンバーのみ編集・削除・復元可(新規追加・チームパスワード変更・オーナー設定は管理者限定でセクションごと非表示)。「変更履歴」の通知範囲はここではなく各自のプロフィール編集モーダルで設定する
- 販路管理モーダル: 販路の新規作成・編集・(条件付き)削除。Leader以上が閲覧可、編集はLeader以上
- 通知設定モーダル: 販路ごとのLINE/Chatwork送信先・有効フラグ・送信時刻(翌日/当日別)。全体まとめ通知の設定もここに統合。「今すぐ送信」ボタンあり
- DBダンプモーダル: 管理者がテーブルを選んで全行を閲覧(パスワード列はマスク済み)。フィルタ入力で行を絞り込み可能
- 変更履歴モーダル(ヘッダー、Leader以上限定): ヘッダーの時計アイコンボタンから開く。プロフィールの通知範囲設定に応じた階層の配下メンバーが自分自身のアカウントで行ったシフト変更を一覧表示し、日時/対象メンバー順にソートできる。各行のチェックボックスで個別既読、または「すべて既読にする」で一括既読化できる。未読件数はボタン右上に赤丸バッジで表示(§5.5)
8通知バッチ(notify-worker)
notify-worker/index.jsが15分おき(*/15 * * * *)にCloudflare Cron Triggerで起動し、以下4種類の通知を順に評価・送信する。
- sendTomorrowNotifications — 販路ごとの翌日稼働メンバー通知
- sendTodayNotifications — 販路ごとの当日稼働メンバー通知
- sendBulkAggregateNotification — 全販路まとめの翌日通知(単一のLINE/Chatwork送信先へ)
- sendTodayBulkAggregateNotification — 全販路まとめの当日通知
現在時刻(JST)は15分単位に切り捨て(currentJstHourMinute)、各販路/設定に登録された通知時刻と完全一致した回のみ送信する(15分粒度のスケジューラ)。一致しなければ何もしない。
メッセージ組み立てルール
- 稼働者は「氏名 開始-終了」形式、備考があれば次の行に追記
- 他販路がメインのメンバーが掛け持ちで入っている場合、「(掛け持ち・メインは○○)」を自動付記
- 休みの人は「😴休みの人:」の見出しの下にまとめて列挙(備考=理由があれば併記)
- 連絡不可の人は「本日連絡不可の人:」の見出しの下に列挙
- 稼働者・休み・連絡不可のいずれも0件の販路は、その販路の通知自体を送らない(
_buildChannelSectionがnullを返す)
送信・ログ
- LINE:
POST https://api.line.me/v2/bot/message/push(Bearer認証、LINE_CHANNEL_ACCESS_TOKEN) - Chatwork:
POST https://api.chatwork.com/v2/rooms/{roomId}/messages(X-ChatWorkTokenヘッダー) - 送信結果(成功/失敗・エラー内容)は必ず
NotificationLogsに1行記録される(失敗しても例外は握りつぶしログ化のみ) - バッチの最後に
SessionRepository.removeExpired()で期限切れの個人セッションを掃除する - 多重送信対策(§12): 実送信の直前に
NotificationService._wasAlreadySentRecentlyが、同一販路ID・送信先種別・本文の送信成功ログが直近14分以内に無いかを確認し、あれば送信をスキップする。Cron Triggerの重複起動や想定外の呼び出しが発生しても、実際の外部送信は1回に抑えられる - Worker本体への手動アクセス(
GET /等の未知パス)は何も送信しない。通知を今すぐ手動送信したい場合のみGET /run-notificationsを明示的に呼ぶ(要注意: 実際に送信される)
日次ヘルスチェック(毎朝6:00 JST)
15分おきの通知バッチとは別に、0 21 * * *(UTC、JST 6:00)のCron Triggerで以下2つを毎朝チェックし、結果をSettingsテーブルへ保存する。画面右上のバッジ(§7)はこの保存値を読むだけで、Cloudflare/LINEのAPIは叩かない。
- D1ストレージ使用量(
storageService.js) — Cloudflare D1の管理APIからfile_sizeを取得し、無料枠5GBに対する使用率を計算 - LINE月間メッセージ送信数(
lineQuotaService.js) — LINE Messaging APIの/v2/bot/message/quotaと/quota/consumptionから、当月の送信上限・送信済み件数・使用率を計算(無制限プランの場合はバッジ非表示) - 変更履歴(ShiftHistory)の保持期間整理(
ShiftRepository.pruneHistoryOlderThan) — DB容量節約のため、変更日時が2ヶ月より古いShiftHistory行と、対応するUserHistoryReadEntriesの既読行を削除する(§5.5)。手動実行用にGET /prune-shift-historyもある - 通知送信ログ(NotificationLogs)の保持期間整理(
NotificationService.pruneLogsOlderThan) — 同じくDB容量節約のため、送信日時が2ヶ月より古い行を削除する。手動実行用にGET /prune-notification-logsもある
9デプロイ・環境変数
必要な環境変数(Cloudflare Pages: Settings > Environment variables)
| 変数名 | 用途 |
|---|---|
ACCESS_PASSWORD | チームゲートの共有パスワード本体 |
TEAM_TOKEN_SECRET | teamTokenのHMAC署名鍵(ランダムな長い文字列) |
LINE_CHANNEL_ACCESS_TOKEN | (任意)LINE通知を使う場合。notify-worker側にも同じ値が必要 |
CHATWORK_API_TOKEN | (任意)Chatwork通知を使う場合。notify-worker側にも同じ値が必要 |
D1データベースは環境変数ではなくwrangler.tomlの[[d1_databases]]でバインドする(binding = "DB")。Pages側とnotify-worker/wrangler.tomlは同一のdatabase_idを指す必要がある。
初回セットアップ手順
npx wrangler login npx wrangler d1 create nia-shift-db # 表示されたdatabase_idを wrangler.toml と notify-worker/wrangler.toml に設定 npx wrangler d1 execute nia-shift-db --remote --file=migrations/0001_init.sql npx wrangler pages deploy public --project-name nia-shift # Cloudflareダッシュボードで環境変数を設定 → 再デプロイ cd notify-worker && npx wrangler deploy
日常のデプロイ
npm run deploy # = wrangler pages deploy public --project-name nia-shift npm run migrate # = wrangler d1 execute nia-shift-db --file=migrations/0001_init.sql
notify-worker/index.jsを変更した場合はcd notify-worker && npx wrangler deployを別途実行する必要がある。10開発の経緯
| フェーズ | 構成 | 状態 |
|---|---|---|
| 1. GAS版 | Google Apps Script + Google Sheets DB + HTML Service | レガシー・参考(更新停止・通知トリガーも2026-09-30に削除済み) |
| 2. Netlify検討版 | netlify-app/(このリポジトリには含まれない) | 採用見送り |
| 3. Cloudflare版(現行) | Pages + Functions + D1 + Worker | 本番稼働中 |
GAS版からのデータ移行はscripts/migrate-from-sheets.cjs(Google Sheets APIで全シート読み込み→INSERT文生成)を1回だけ実行し、生成されたmigrations/9999_migrate_data.sqlをwrangler d1 execute --remoteでD1へ反映した。以後このスクリプトの再実行は想定していない(ワンタイム移行ツール)。
Cloudflare化に伴う主な設計変更: D1へのSQL直接クエリ化(全件取得+JSフィルタの廃止による高速化)、祝日データをGoogle公開iCalの都度取得に変更、15分おき通知を独立Workerへ分離。
sendScheduledNotificationsTrigger、src/SetupSystem.jsのsetupTrigger()で登録)が、データ移行後もGASプロジェクト側に残ったまま動き続けていたことが判明(Apps Scriptの時間主導トリガーはWebアプリの利用有無と無関係に、明示的に削除するまで動き続ける)。Cloudflare版の「当日まとめ通知」と同時刻(7:00)に重複して通知が飛んでいたため、GASエディタの「トリガー」画面から手動で削除し、解消済み。今後GAS版(src/)を再度動かす予定がある場合は、同様の重複に注意すること。
11既知の制限・注意点
- チームパスワード変更UIが非機能: ユーザー管理モーダルに「チームパスワードの変更」欄が存在するが、対応する
api_updateAccessPasswordは常にエラーを返すダミー実装。実際の変更はCloudflare環境変数ACCESS_PASSWORDを書き換えて再デプロイする必要がある。UIを削除するか、実装を合わせるかの整理が望ましい - 権限のハードコード: メンバー削除・通知送信先管理が特定1アカウントの氏名文字列に紐づいている(§5.4)。改名・退職時に運用が詰まるリスクがある
- パスワードハッシュ方式:
SHA-256(salt + password)はソルト付きだが、bcrypt/scrypt/Argon2のような低速化ハッシュではないため、DBが漏洩した場合のオフライン総当たりへの耐性は高くない。社内利用・低脅威モデルとしては許容範囲だが、より強固にする余地はある - teamTokenの性質: 個人を識別しない共有シークレット方式のため、退職者が把握しているチームパスワードは変更されるまで有効であり続ける
- 祝日取得の外部依存: Googleの公開iCalフィードが仕様変更・停止した場合、祝日表示機能のみ静かに失敗する(グリッド自体は表示される)
- RPC方式ゆえの型安全性:
fn文字列とargs配列の順序はフロント/バック双方で手動同期が必要。関数シグネチャ変更時は両ファイルの整合を目視確認する必要がある
12セキュリティ対応履歴
migrations/9999_migrate_data.sql が誤って含まれていたことが判明。同日中に以下の対応を実施済み:
- 該当ファイルをリポジトリから削除し、
.gitignoreに追加(以後追跡対象外) - 初回コミットのみだったため
git commit --amend+git push --forceでGitHub上の履歴自体からも完全に除去(force push時点で他者によるcloneは無し) - 同様に実データ/内部情報を含む
db-dump.html・infra-map.html(チームパスワード等を平文記載)もGit管理対象外に設定 - 運用基盤マップ(
infra-map.html)経由でスタッフに共有する版は、既存のチームゲート認証(§5.1)を流用してアクセス制限をかけた上で公開
今後、実データを含む生成物(SQLダンプ・エクスポート等)を扱う際は、コミット前にgit statusで差分内容を確認することを推奨する。
- 問題:
直属リーダーUserIDの更新(api_updateUser)は、指定した相手が現に自分自身の配下(直属・間接問わず)かどうかを検証していなかった。Leader以上のユーザーが自分の直属リーダーUserIDを自分の配下の誰か(直属でなくても可)に付け替えると、階層が循環し、_isDescendantOfによる祖先判定が誤って真になる。その結果、本来配下でしかないそのメンバーが_assertCanManageUserを通過し、付け替えた本人を編集・ソフトデリート・復元できてしまう権限昇格が成立していた - 修正:
_validateLeaderに循環チェックを追加。指定した直属リーダーが、変更対象ユーザー自身の(現時点での)配下である場合はエラーとして拒否する(functions/api.jsの_validateLeader、api_updateUser/api_createUserから呼び出し) - 影響範囲: この脆弱性は今回のセッションで実装したLeader以上へのユーザー管理開放(§5.4)と同時に作り込まれたもので、本番反映からこの修正までの間に悪用された形跡がないか確認が必要な場合は
ShiftHistoryではなく将来的な監査ログの整備を検討のこと(現状直属リーダーUserIDの変更履歴自体は記録されていない)
- 調査: ユーザー報告の「GAS版が動いていないか」という疑いに対し、
NotificationLogsを確認したところ、今朝(2026-10-02)07:00台の「当日まとめ通知」が1つのスロット(07:00〜07:14 JST)内でLINE・Chatworkともに3回ずつ、しかも2回目と3回目は7〜8秒差という極めて短い間隔で実送信されていたことが判明。GAS版のトリガーは既に削除済み(§10)だったため、この重複はCloudflare側の問題と断定 - 原因1:
notify-worker/index.jsのfetchハンドラが、/check-storage等の既知パス以外のあらゆるパス(ベースURLへの単純アクセスも含む)で無条件にrunNotifications()(実際の通知送信)を実行していた。このベースURLはinfra-map.htmlで「手動実行URL」として公開されており、bot・リンクプレビュー取得・誤クリック等による予期しないアクセスが、該当スロット内に複数回発生すると、その都度本物の通知が実送信されてしまう - 原因2: 通知送信処理(
NotificationService._sendAndLog)に「同じ内容を直近に送信済みかどうか」を確認する仕組みが無かったため、Cron Triggerの重複起動や上記のベースURLアクセスによる多重呼び出しを一切吸収できず、呼ばれた回数分そのまま外部送信されていた - 確認して問題なしと判断した項目: GAS版トリガー(既に削除済み・この重複は削除後の日付のログでも発生していたためGASは無関係と確認)、
wrangler.tomlのCron Trigger定義自体の重複(無し)、「全体まとめ通知」と「販路ごとの個別通知」のChatwork送信先重複(1販路のみ偶然同じルームIDだったが、個別通知自体が全販路で無効化されており無関係) - 修正1:
fetchハンドラを変更し、通知送信は明示的に/run-notificationsパスを指定した場合のみ実行。ベースURL・その他未知のパスへのアクセスは何も送信せずステータス文言のみ返す - 修正2:
NotificationService._sendAndLogに_wasAlreadySentRecentlyチェックを追加。同一販路ID・送信先種別・本文の送信成功ログが直近14分以内に存在する場合は送信自体をスキップする(多重呼び出しへの保険として、原因1を塞いだ後も残す) - infra-map.htmlの該当リンクも、ベースURL(安全)と
/run-notifications(要注意)を明確に分けて記載するよう更新
Shiftsテーブルに同一メンバー・同一日付の重複行が複数存在していたことが発覚。修正済み。
- 調査: 報告のあったメンバーのシフトを確認すると、2026-10-02分が内容完全一致の2行で登録されていた(作成時刻が2秒差)。範囲を広げて全テーブルを確認すると、他に3名・合計12日分で同様の重複が見つかった(いずれも作成時刻が1〜3秒差で、内容は完全一致)
- 原因:
Shiftsテーブルには(UserID, 日付)の一意性を保証する制約が無かった。かつapi_saveShift(新規作成時)・api_bulkApplyShifts・api_registerMonthはいずれも「対象日の既存シフトを1回だけ確認し、無ければ新規作成」という実装で、同じ登録操作がボタン連打や通信リトライ等でほぼ同時に2回実行されると、両方とも「まだ存在しない」と判定してしまい2行作成される競合状態(race condition)があった - 対応1(データ修正): 重複していた12行(内容は完全一致のため実質無害)のうち、より新しく更新された方を残し、もう一方と対応する
ShiftHistoryの孤立レコードを削除(ユーザーに削除内容を確認のうえ実施) - 対応2(再発防止):
migrations/0007_shifts_unique_user_date.sqlでShifts("UserID", "日付")にUNIQUE制約を追加。ShiftRepository.create/createManyは制約違反を検知した場合、生のSQLiteエラーではなく「このメンバーのこの日付のシフトは既に登録されています。画面を更新してから再度お試しください。」という分かりやすいエラーに変換して返す(_friendlyShiftWriteError)
本仕様書は実際のソースコード(functions/, public/index.html, notify-worker/, migrations/)を精査して作成。コードと本書に差異がある場合はコードを正とする。