年末になると「友達が飾ったメッセージを、特定の日まで開かない」系のプロダクトが増えます。例として Deco My Tree を取り上げます。オンラインのクリスマスツリーを作り、リンクを共有すると、友だちがオーナメントとメッセージを飾れます。ただし 12月25日までは本文が封印 され、ツリーの持ち主は「誰が飾ったか」は分かっても、中身は読めません。
見た目はメッセージボードに近いですが、実装でいちばん壊れやすいのはここです。封印をフロントの表示制御だけでやると、封印になっていません。 本稿では公開ページから読み取れる挙動をもとに、再利用しやすい設計の骨組みを整理します。コードはすべてサンプルコードであり、プロダクトのソースではありません。
まず制約を書き出す
日本語版の説明から、開発者向けの制約に落とし込むとだいたい次のようになります。
- アプリ不要。ブラウザのリンクだけで使える。メッセージ送信時だけ一度ログインが必要
- 12月25日までメッセージはロック。送信者は分かる(匿名を除く)が、本文は見えない
- デフォルトはツリー持ち主のみ閲覧可。書き手が「みんなに公開」を選んだ場合のみ、開封後に他者も読める
- 匿名にすると、持ち主にも「匿名」と表示される
- オーナメントは1ページ12個。満杯になると次のページが増える。受付は通常1月1日まで
「画面上に鍵アイコンを出す」ことではなく、開封日より前は、クライアントが取得できる応答に本文を載せないことが本丸です。
封印はシリアライズの段階で行う
よくある失敗は、APIが body をそのまま返し、フロントが Date.now() と開封日を比較して描画を止めるやり方です。DevTools やパケットキャプチャで全文が見えるので、封印は一瞬で崩れます。
より安全なのは、サーバがシリアライズ時に「いまの時刻 + 閲覧権限」でフィールドの有無を決めることです(サンプルコード)。
type Wish = {
id: string;
treeId: string;
ornament: string;
authorId: string;
authorName: string;
isAnonymous: boolean;
isPublic: boolean;
body: string;
createdAt: Date;
};
// サンプル: UTC の 12-25 00:00 を開封時刻にする
const openAt = (year: number) => new Date(Date.UTC(year, 11, 25));
function serializeWish(
wish: Wish,
viewerId: string | null,
ownerId: string,
now = new Date(),
) {
const opened = now >= openAt(now.getUTCFullYear());
const isOwner = viewerId === ownerId;
const canRead = opened && (isOwner || wish.isPublic);
return {
id: wish.id,
ornament: wish.ornament,
author: wish.isAnonymous ? 'Anonymous' : wish.authorName,
sealed: !canRead,
body: canRead ? wish.body : null,
};
}
ポイントは3つです。
-
本文を欠落させる。
sealed: trueのときbodyはnull。フロントは「未開封」状態を描くだけ。 -
匿名と封印を同じ出口で扱う。 DBには通報・監査用に
authorIdを残してよく、対外レスポンスには実名を出さない。 -
開封瞬間のタイムゾーン戦略を先に決める。 上記の UTC は例です。「ユーザ現地の12月25日0時」を約束するなら、ツリー作成時に
timezoneを保存し、比較はそのゾーンで行う方が筋が良いです。
タイムゾーン:「日付」は瞬間である
「12月25日に開封」は日付に聞こえますが、システム上はタイムゾーン付きの瞬間です。
| 戦略 | 利点 | 代償 |
|---|---|---|
| 固定 UTC | 実装が単純で世界一斉 | 体感が前後する地域がある |
| 固定の運用タイムゾーン | 運用メッセージが揃う | 越境ユーザへの説明が必要 |
| ツリーごとにタイムゾーン | 持ち主の現地感覚に近い | シリアライズとキャッシュが複雑 |
どれを選んでも、開封判定の権威はサーバ時計だけです。クライアント時刻は改ざんできるので認可に使えません。UIのカウントダウン表示に留めるのが安全です。
キャッシュと CDN が開封日を「引きずる」
封印解除とは、同じ URL の応答が、ある瞬間以降に中身を変えるという意味です。ツリーページやメッセージ一覧を CDN で長くキャッシュすると、「カレンダーは25日なのに画面はまだ封印」が起きます。
取りうる制約の例です。
- メッセージ一覧は
Cache-Control: private, no-store - キャッシュが必要なら、TTL を開封時刻までに切り詰める(サンプル)
function cacheSeconds(now = new Date(), base = 300) {
const untilOpen = Math.floor(
(openAt(now.getUTCFullYear()).getTime() - now.getTime()) / 1000,
);
return untilOpen > 0 ? Math.min(base, untilOpen) : base;
}
開封後の短TTLは許容できますが、権限付き応答を別ユーザに流さないよう、認証付きAPIは private が無難です。
ページング:満杯になったら次のページ
公開説明ではオーナメントは12個ごとにページが増えます。ページング自体は簡単ですが、並び順が「昔の友だちのオーナメントを探しづらくする」かどうかを決めます。
const PER_PAGE = 12;
function treePage(wishes: Wish[], page: number) {
const sorted = [...wishes].sort((a, b) => +a.createdAt - +b.createdAt);
return {
page,
totalPages: Math.max(1, Math.ceil(sorted.length / PER_PAGE)),
slots: sorted.slice((page - 1) * PER_PAGE, page * PER_PAGE),
};
}
作成時刻の昇順で切ると、新しい祝福は末尾に付き、既存スロットが押し出されません。「ツリーを飾る」心象には、常に最新を先頭へ寄せるより向いています。
受付停止と通報
時間ロックとセットになりやすい運用機能は2つです。
- 書き込み停止: 1月1日以降は作成を拒否。持ち主が早めに受付OFFした場合も同じ検証パスに載せる(フロントでボタンを灰にしただけ、では不十分)
- 通報: 対外表示が匿名でも、サーバ側には追跡可能な識別子を残す。メッセージから通報でき、通報内容は管理者だけが見る——これは「シリアライズで名前を隠す」ことと矛盾しません
フロントの役割
封印がサーバに落ちたあとも、フロントの仕事ははっきりしています。
-
やる:
sealedに応じたロック表示/カウントダウン(表示専用)。送信時にオーナメント種別と公開/匿名を付ける -
やらない: 未開封の
bodyを先読みして「時刻が来たら出す」。本文入りレスポンスをlocalStorageに溜める。クライアント時刻で「開封済みAPI」を呼ぶか決める
自分の書いたメッセージのプレビューも、「作者本人は読める」というサーバ規則に載せる方が、別経路のプレビューAPIより漏れにくいです。
ほかの場面への転用
「期限まで読めない」はクリスマス専用ではありません。誕生日の寄せ書き、卒業メッセージ、チームの年次レビュー、コンプライアンス上のクーリングオフ後公開など、同じ型が使えます。
- サーバのシリアライズで可視フィールドを決める
- 開封瞬間のタイムゾーン意味を明文化する
- キャッシュTTLを開封点より長くしない
- 匿名表示と監査用フィールドを分離する
実際の流れを手で確かめたい場合は、ブラウザで Deco My Tree を開き、無料でツリーを1本作ってみるとよいです。時間ロックは スタイル問題ではなく認可問題 として扱うと、後から戻るコストがかなり減ります。


