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)」に所属し、日付ごとに「稼働 / 休み / 連絡不可」のいずれかのステータスでシフトを登録する
- 管理者・一般ユーザーの権限に加えて、5段階の「役職(New Beginner〜Owner)」で表示・操作範囲が変わる
- 15分おきのバッチ(notify-worker)が、販路ごと・全体まとめの両方でLINE/Chatworkに自動通知を送る
- 全操作(作成・更新・削除)は
ShiftHistoryテーブルに変更履歴として記録される
1技術スタック
| 領域 | 技術 | 備考 |
|---|---|---|
| フロントエンド | 素のHTML + CSS + JavaScript(public/index.html 1ファイル) | フレームワーク不使用。約3,760行のSPA |
| バックエンドAPI | Cloudflare Pages Functions | functions/api.js。/apiへの単一POSTエンドポイント(RPC方式) |
| データベース | Cloudflare D1(SQLite互換) | 8テーブル。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)上の8テーブル。全てmigrations/0001_init.sqlで定義され、列名は日本語(GAS版のSheetsヘッダーをそのまま踏襲)。
4.1 Users(メンバー)
| 列 | 型 | 内容 |
|---|---|---|
UserID | TEXT PK | USR-<timestamp>-<random>形式で自動採番 |
氏名 / よみがな | TEXT | 表示名・ふりがな(あいまい検索・五十音ソートに使用) |
所属販路ID | TEXT | メインで所属する販路(SalesChannels.販路ID) |
権限 | TEXT | 管理者 / 一般の2値 |
役職 | TEXT | New Beginner / Beginner / Leader / Manager / Owner の5段階(§5) |
直属リーダーUserID | TEXT | 樹形図表示・並び替えの親子関係に使用 |
優先順位 | INTEGER | 同グループ内の任意の表示順(未設定可) |
自分を先頭表示 | INTEGER(0/1) | グリッドで自分の行を常に先頭に固定表示するか |
PasswordHash / PasswordSalt | TEXT | SHA-256(salt + password)。ソルトはcrypto.randomUUID() |
メールアドレス | TEXT | 現行フローでは未使用(GAS版のGoogleアカウント認証の名残。常に空文字) |
4.2 SalesChannels(販路マスタ)
| 列 | 内容 |
|---|---|
販路ID | PK。CH-<timestamp>-<random> |
販路名 / 表示順 | 一覧・グリッドでの並び順 |
商材 / 備考① / 備考② / クライアント / 場所 | すべて自由記述の付帯情報 |
4.3 Shifts / ShiftHistory
| 列 | 内容 |
|---|---|
ShiftID | PK。SFT-<timestamp>-<random> |
日付 | yyyy-MM-ddのプレーン文字列(タイムゾーン変換はJS側で行う) |
UserID / 販路ID | 誰の・どの販路のシフトか |
ステータス | 稼働 / 休み / 連絡不可 |
開始時刻 / 終了時刻 | ステータス=稼働の時のみ値を持つ(他の値に変更すると自動でクリアされる。_normalizePatch) |
備考 | 自由記入。連絡不可の理由もここに統一 |
更新日時 / 更新者UserID | 保存のたびに自動更新 |
| ShiftHistory(変更履歴。Shiftsの操作ごとに自動追記) | |
|---|---|
HistoryID | PK。HIS-<timestamp>-<random> |
ShiftID / 変更日時 / 変更者UserID | 対象・タイミング・実行者 |
変更項目 | 列名、または新規作成 / 削除 |
変更前 / 変更後 | 作成時は変更前が空、削除時は変更後が空でJSON文字列を格納 |
update/updateKnown)は、変更前後を列ごとに文字列比較し、差分があった列だけ1行ずつShiftHistoryに追記する。新規作成・削除は1行にまとめてJSON化して記録する。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
| テーブル | 役割 |
|---|---|
NotificationLogs | 全通知送信の成否ログ(日時・販路ID・LINE/Chatwork種別・成否・本文・エラー内容)。販路ID='ALL'は全体まとめ通知 |
Settings | Key/Value型の汎用設定テーブル。オーナーUserID・全体まとめ通知の設定(送信先・時刻・有効フラグ、翌日/当日分)を保持 |
Sessions | 個人ログインセッション(Token PK、有効期限1週間)。notify-worker実行のたびに期限切れ行を自動削除 |
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)による操作範囲
- New Beginner / Beginner: グリッドで他の New Beginner ランクのメンバーが非表示になる(
_visibleUsersFor)。新人同士の相互閲覧を制限する設計 - Leader以上: 販路の新規作成・編集が可能(
_assertLeaderOrAbove)。それ未満は閲覧のみ - 役職は権限(管理者/一般)とは独立した軸。一般ユーザーでもLeaderなら販路編集ができる
5.4 権限マトリクス
| 操作 | 必要な条件 |
|---|---|
| シフト閲覧 | teamTokenのみ(個人ログイン不要) |
| 自分のシフト編集 | 個人ログイン必須 |
| 他人のシフト編集 | 権限=管理者 |
| メンバー新規登録・編集 | 権限=管理者 |
| 販路の新規作成・編集 | 役職=Leader以上 |
| 販路の削除 | オーナー(Settings.OWNER_USER_ID)、または下記「特定アカウント」 |
| メンバーの削除 | 下記「特定アカウント」のみ(氏名の完全一致でハードコード) |
| 通知送信先(Destinations)の管理 | 下記「特定アカウント」のみ(氏名の完全一致でハードコード) |
| DB生データ閲覧(マスク付き) | 権限=管理者 |
functions/api.js内のMEMBER_DELETE_OWNER_NAME定数(特定1名の氏名の完全一致)でハードコードされている。実際の氏名はソースコード(functions/api.js冒頭)を直接確認のこと。このアカウントを削除・改名すると、誰もこれらの操作を行えなくなる。将来的には氏名ではなく役職・フラグベースのロール化を推奨。
6API リファレンス
全エンドポイントはPOST /api に { "fn": "関数名", "args": [...] } を送る単一のRPC方式(functions/api.js)。凡例: 公開=認証不要、チーム=teamToken必須、個人=セッションtoken必須、管理者=権限チェック、特定=特定アカウントのハードコード判定。
| 関数 | 認可 | 概要 |
|---|---|---|
api_verifyTeamAccess | 公開 | チームパスワードを検証しteamTokenを発行 |
api_getLoginableUserNames | 公開 | ログイン画面の名前コンボボックス用に、全ユーザーの氏名/よみがなだけ返す |
api_login | チーム | 個人パスワード照合 → セッション発行 |
api_register | 管理者 | 新規メンバーのセルフ登録相当(実際は管理者が代理登録) |
api_updateProfile | 個人 | 自分の氏名・よみがな・所属販路・パスワードを更新 |
api_updateSelfTopPreference | 個人 | グリッドで自分の行を先頭固定表示するか切替 |
api_getBootstrap | チーム | 初期表示用の販路一覧・ユーザー一覧をまとめて取得 |
api_getShifts | チーム | 指定期間のシフトを取得(閲覧可能ユーザーのみにフィルタ) |
api_getHolidays | チーム | Google祝日iCalを都度取得・パースして返す(DB非保存) |
api_getShiftHistory | チーム | 1シフトの変更履歴一覧(新しい順) |
api_saveShift | 個人 | 1件のシフト作成/更新(他人分は管理者のみ) |
api_bulkApplyShifts | 個人 | グリッドで複数セル選択→同一内容を一括反映 |
api_registerMonth | 個人 | 「月まとめて登録」モーダルからの一括新規登録 |
api_deleteShift | 個人 | 1件のシフト削除(履歴に記録) |
api_getUsersAdmin | 管理者 | ユーザー管理モーダル用の詳細一覧(パスワード設定有無を含む) |
api_createUser | 管理者 | 管理者画面からのメンバー新規作成 |
api_updateUser | 管理者 | メンバー情報の更新(最後の管理者の権限降格は禁止) |
api_updateAccessPassword | — | 常にエラーを返すダミー実装(§11参照) |
api_getChannelsAdmin | チーム | 販路管理モーダル用の全項目付き一覧 |
api_createChannel | Leader以上 | 販路の新規作成 |
api_updateChannel | Leader以上 | 販路情報の更新 |
api_deleteChannel | 特定 | 販路削除。オーナー、または特定アカウントのみ |
api_claimChannelOwner | 管理者 | ログイン中の管理者を「オーナー」としてSettingsに登録(未設定時のみ) |
api_deleteUser | 管理者+特定 | メンバー削除。実行条件は管理者かつ特定アカウント(§5.4) |
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) - プロフィール編集モーダル: 自分の氏名・よみがな・所属販路・パスワード変更
7.2 シフトグリッド(メイン画面)
- 行=メンバー、列=日付のカレンダーグリッド。週表示/月表示を切替可能(
state.viewMode) - 並び替えは5種類: 樹形図順(直属リーダーの階層)・販路順・名前順(よみがな)・優先順位順・役職順。各々昇順/降順切替あり
- セルの色分け: 稼働=通常色+時間表示、休み=グレー「休」、連絡不可=赤背景「連絡不可」(備考があればツールチップ表示)
- 土日は列の背景色を変えて視認性を確保(
col-sun/col-sat) - 祝日はAPI経由で取得し、該当日の列にマーキング
7.3 セル選択・一括編集
- 複数セル選択モード: マウスドラッグで矩形選択 → 「一括反映モーダル」で同一のステータス/時間帯/備考をまとめて適用(
api_bulkApplyShifts) - 詳細パネル(1セル): クリックで開き、「編集」タブと「変更履歴」タブを持つ。履歴タブは開いたときに遅延ロード
7.4 自分のシフトをまとめて操作する2つの画面
| 画面 | 用途 | API |
|---|---|---|
| シフト登録モーダル(月まとめて登録) | まだ登録していない日に対し、当月/翌月分をまとめて同じ稼働パターンで新規登録 | api_registerMonth |
| シフト変更モーダル | 既存の1ヶ月分を行ごとにその場編集(管理者は対象メンバーを切替可能) | api_saveShift(行単位) |
7.5 管理者向け画面
- ユーザー管理モーダル: メンバー一覧・追加・編集・(条件付き)削除、チームパスワード変更UI(※非機能。§11)、販路削除オーナーの自認定ボタン
- 販路管理モーダル: 販路の新規作成・編集・(条件付き)削除。Leader以上が閲覧可、編集はLeader以上
- 通知設定モーダル: 販路ごとのLINE/Chatwork送信先・有効フラグ・送信時刻(翌日/当日別)。全体まとめ通知の設定もここに統合。「今すぐ送信」ボタンあり
- DBダンプモーダル: 管理者がテーブルを選んで全行を閲覧(パスワード列はマスク済み)。フィルタ入力で行を絞り込み可能
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()で期限切れの個人セッションを掃除する
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 | レガシー・参考(更新停止) |
| 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へ分離。
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で差分内容を確認することを推奨する。
本仕様書は実際のソースコード(functions/, public/index.html, notify-worker/, migrations/)を精査して作成。コードと本書に差異がある場合はコードを正とする。