「ほかの日からコピー」のcopyOneDayが固定連絡を黙って消していた
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
「ほかの日からコピー」は対象日の連絡欄を全置換する実装で、AI反映にあった固定連絡(pinned)の保全関数 preservePinnedNotices がコピー経路だけ呼ばれておらず、教員が入力していた固定連絡がコピーの瞬間に黙って消えていた。
結論
「ほかの日からコピー」の内部関数 copyOneDay は、対象日の連絡欄(notices)を複製元の内容で全置換する実装だった。AI が連絡欄を書き換える経路ではすでに使っていた固定連絡(pinned・「ずっと」表示)の保全関数 preservePinnedNotices を、コピーの書き込み経路だけ呼んでいなかったため、教員が対象日に入力していた固定連絡はコピーを実行した瞬間に黙って消えていた。
症状
サイネージエディタには、前日・前週の連絡や予定を対象日へ複製する「ほかの日からコピー」がある。連絡欄には教員が「ずっと」表示させたい項目を固定(pinned)にする機能があり、AI が連絡内容を反映する経路ではこの固定連絡を消さないよう保全する処理が入っていた。
ところがコピー機能の内部コア copyOneDay は、対象日の notices を複製元の daily.get("notice") でそのまま置き換えていた。
// 2026-07-18 14:36 時点(#1292 マージ直後): 全置換するだけで、対象日の固定連絡を見ていない
if (block === "schedule" || block === "notice" || block === "assignment") {
const items = daily.get(block) ?? [];
const field =
block === "schedule" ? "schedules" : block === "notice" ? "notices" : "assignments";
await upsertDailySectionForTarget(tx, actor, target, toDate, field, items);
count = items.length;
}
複製元の連絡に固定行が含まれているかどうかに関わらず、対象日側にあった固定連絡はこの upsertDailySectionForTarget の呼び出しで丸ごと上書きされ、消える。教員から見ると、対象日に入力していた「ずっと」表示の連絡が、コピー操作のあと理由もなく無くなっている状態になる。
原因
このコピー機能は、前日コピーと前週コピーという別々の実装を「ほかの日からコピー」という1つのツールへ統合した #1289 で今の形になった。連絡欄の全置換自体は元々の前日/前週コピーにもあった挙動で、「その日の写し」を作るという設計としては正しい。
問題は、それとは別に AI が連絡欄を書き換える経路(assistant-chat-core)には、固定連絡を保全する preservePinnedNotices という関数がすでに用意されていたことだった。連絡欄を書き換えるという同じ操作なのに、AI 経路には保全があり、コピー経路には無い。この非対称に、#1289 のレビューで指摘が入った。
#1289 のマージが 14:29、直後に undo(元に戻す)機能を足す #1292 が 14:36 にマージされている。#1292 は「コピーしたら困ったときに戻せる」という保険であって、固定連絡が消えること自体を防ぐものではない。教員は固定連絡が消えたと気づかない限り undo を使う理由がなく、実質的な救済にはならなかった。
直す
copyOneDay の連絡書き込みだけ、AI 反映と対称になるよう preservePinnedNotices を通すようにした。
// #1310(16:39 マージ): 上書き前に控えた対象日の RAW スナップショット(before)から
// 固定行を前置き合流させてから書き込む
const toWrite =
block === "notice"
? preservePinnedNotices(
[{ date: toDate, items: (before.notice ?? []) as NoticeItem[] }],
toDate,
items as NoticeItem[],
)
: items;
await upsertDailySectionForTarget(tx, actor, target, toDate, field, toWrite);
before は undo 用にすでに読んでいた「上書き前の対象日スナップショット」を、書込みと同一トランザクション内でそのまま再利用している。追加の読み取りは発生していない。
複製元の固定連絡との二重表示も起きない。複製元を読む readCopySource の copyableNoticeItems が、複製元側の固定連絡を最初からコピー対象(items)に含めていないため、preservePinnedNotices が足すのは対象日にもともとあった固定連絡だけになる。
再発防止
このパターンの怖さは、schedule / notice / assignment という3種類のブロックのうち、保全が要るのは notice だけだという点にある。同じ if 分岐の中を書き換える際、notice の分岐にだけ気を配る必要があり、次に似た書き込み経路を増やしたときも同じ抜けが起きうる。
そのため copy-pinned-notice.test.ts では、DB・認可・パターン解決といった周辺のシームはスタブしつつ、copyableNoticeItems と preservePinnedNotices という pinned 判定の純ロジックはモックせず実体のまま通し、upsertDailySectionForTarget に実際に渡る notices の中身を検証するようにした。周辺を止めて中核だけ実測する形にしたことで、次に書き込み経路が増えても、保全が抜けていればこのテストが落ちる。
よくある質問
Q1「元に戻す(undo)」があるなら実害は無いのでは?
undo で復旧はできるが、教員側は固定連絡が消えたこと自体に気づく手段が無い。undo は『消えたと気づいたあと』にしか使えず、コピー実行前に事故を防ぐものではない。undo 機能自体は同日先に別PR(#1292)で入っていた。
Q2対象日の固定連絡を保全すると、複製元の固定連絡と二重表示にならないか?
ならない。複製元を読む readCopySource の copyableNoticeItems が、複製元の固定連絡を最初から複製対象から除外している。保全処理が足しているのは対象日にもともとあった固定連絡だけで、複製元の固定連絡はどのみちコピーされない。
Q3固定連絡の保全処理はどこで実装されていたか?
assistant-chat-core の preservePinnedNotices という関数。AI が連絡欄を書き換える経路ではこの関数を通していたが、コピー機能の書き込み経路にはこの呼び出しが無かった。
Q4この欠陥はどのくらいの間、本番に存在したか?
確認できる範囲では同日中。前日/前週コピーを『ほかの日からコピー』に統合した #1289 のマージ(14:29)から、保全を足した #1310 のマージ(16:39)まで約2時間。レビューで指摘され、その日のうちに直っている。
Q5再発防止のテストはどう書いたか?
copy-pinned-notice.test.ts を追加した。DB・認可・パターン解決の各シームはスタブしつつ、pinned 判定の純ロジック(copyableNoticeItems・preservePinnedNotices)はモックせず実体のまま通し、upsertDailySectionForTarget に渡る notices を実測する回帰テストにした。
確認した環境
- Next.js 16.0 / React 19.0 / TypeScript 6.0.3 / Vitest 3.2.6
- 2026-07-18 にレビューで発見、同日中(約2時間後)に修正
この記事の根拠
- TypeScriptファイル 255〜262行目コミット 7e83b3c
- TypeScriptファイル 261〜283行目コミット f6db3a0
- TypeScriptファイル 1〜20行目コミット f6db3a0
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。