社内ヘルプデスクでは、問い合わせが「受け付けたまま誰も触っていない」状態になるのが一番の困りごとです。担当者が休みだった、優先度の高いものが埋もれていた、といった理由で、気づいたときには数日たっていた、ということが起こります。
「急ぎの問い合わせが放置されていないか、毎朝一覧を見て確かめるのが大変。期限を過ぎたら自動で知らせてほしい」
結論から言うと、優先度ごとに対応期限(SLA)を決め、Cloudflare Workers の Cron Triggers(定期実行)で15分ごとに期限超過を探し、通知は前回までのキューに任せるのが扱いやすい形です。定期実行で一番気をつけるのは「同じチケットを毎回知らせてしまう」ことなので、この回では二重通知を2段階で防ぎます。
前回は、コメントと担当者の割り当て、変更の履歴を作りました。まだの方は先にご覧ください。
詳しくは前回の記事で解説しています。


この回のコードは、前回までのプロジェクトに足していきます。新しいライブラリのインストールは不要です。
期限超過の検知で決めておく3つのこと
SLA(Service Level Agreement、サービスの水準の取り決め)は、ここでは「この優先度の問い合わせは、受け付けてから何時間以内に解決を目指す」という社内の目安のことです。作り始める前に、次の3つを決めておきます。
| 決めること | この連載での決め方 | 理由 |
|---|---|---|
| 期限の長さ | 優先度「高」4時間・「通常」24時間・「低」72時間(登録からの経過時間) | 優先度ごとに急ぎ具合が違うため。数字は例で、会社ごとに決める |
| 何をもって「期限内」とするか | 「解決(resolved)」か「完了(closed)」になっていれば期限内 | 「対応中」のまま止まっているものも拾うため |
| 誰に知らせるか | 担当者と管理者。担当者が未割り当てなら担当者・管理者の全員。申請者には送らない | 遅れの連絡は、社内で対応を決めてから申請者に伝えるため |
期限の数え方には、大きく2つあります。
- 経過時間で数える:登録から24時間たったら超過。作りは簡単ですが、夜間・休日も時間が進みます
- 営業時間で数える:平日9時〜18時だけを数える。実態に合いますが、祝日や会社の休みの表を持つ必要があり、作りが一段複雑になります
この連載では経過時間で数えます。営業時間で数える場合の考え方は「つまずきやすい点」で触れます。
Cron Triggers の仕組みと、二重通知が起きる理由
Cron Triggers は、Workers を決まった時刻に動かす仕組みです。cron 式(「15分ごと」「毎日9時」などを */15 * * * * のように書く形式)を wrangler.jsonc に書くと、その時刻に Workers の scheduled という入口が呼ばれます。時刻は UTC(協定世界時) で動くので、日本時間(UTC+9)で「毎朝9時」なら 0 0 * * * と書きます。
📰 出典:Cloudflare Docs「Cron Triggers」
定期実行で期限超過を探すと、何もしなければ「15分ごとに同じチケットを見つけて、同じメールを送る」ことになります。また、前回までに書いたとおり、キューは同じメッセージを2回渡すことがあります。そこで、二重通知を次の2段階で防ぎます。
| 段階 | 防ぎ方 | 防げること |
|---|---|---|
| 見つける段階 | 知らせたチケットを sla_alerts 表に1行記録し、次からは探す対象から外す | 15分ごとに同じチケットをキューに預けること |
| 送る段階 | 第5回の送信記録(notification_log)で、同じ人に同じ種類の通知を送らない | キューが同じメッセージを2回渡したときの重複 |
この回で作るものの全体像
- マイグレーションで、知らせた記録の表
sla_alertsを足す - 期限の計算と期限超過の検知を
src/sla.tsにまとめる - 期限超過の通知先のルールを
src/recipients.tsに足す - 通知の処理
src/notify.tsに「期限超過」の種類を足す wrangler.jsoncに Cron Triggers を設定し、src/index.tsにscheduledの入口を足す- テストで、期限の判定・二重通知の防止・通知先を確かめる
1. 知らせた記録の表を作る
チケット1件につき1行だけ入る表にします。ticket_id を主キー(表の中で行を1つに決める列)にしておけば、同じチケットの行は2つ入りません。
-- migrations/0007_sla_alerts.sql
-- 第7回: 期限超過を見つけた記録。1件のチケットにつき1行だけ(ticket_id が主キー)。
-- 定期実行のたびに同じチケットを知らせないよう、「もう知らせた」をここで覚える
CREATE TABLE IF NOT EXISTS sla_alerts (
ticket_id INTEGER PRIMARY KEY REFERENCES tickets (id),
due_at TEXT NOT NULL,
detected_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
ローカルの D1 に当てます。
npx wrangler d1 migrations apply helpdesk --local
2. 期限の計算と検知を書く
期限の判定と、見つけたものをキューに預ける処理を1つのファイルにまとめます。ポイントは次の3つです。
- D1 には日時を
2026-11-14T03:00:00Zの形の文字列で入れているので、比べる側も同じ形にそろえれば、文字列のまま大小を比べられます - 1回の定期実行で扱うのは100件まで。キューの
sendBatch(まとめて預ける機能)が1回100件までのためです。残りは次の回(15分後)に拾います - 順番は「キューに預ける → 記録する」。預けるのに失敗したら記録しないので、次の回にやり直せます。逆に記録だけ失敗して次の回にもう一度預けても、送る段階の送信記録で止まるので、同じ人に2通は届きません
📰 出典:Cloudflare Docs「Queues – JavaScript APIs」
// src/sla.ts
// 期限超過の検知。Cron Triggers(定期実行)から呼ぶ
import type { NotificationMessage } from './notify'
// 優先度ごとの「解決までの目安の時間」。営業時間ではなく、登録からの経過時間で数える
export const SLA_HOURS = { high: 4, normal: 24, low: 72 } as const
export type Priority = keyof typeof SLA_HOURS
// D1 に入っている日時と同じ形(2026-11-14T03:00:00Z。ミリ秒なし)にそろえる。
// この形なら文字列のまま大小を比べられる
export const toDbTime = (d: Date) => d.toISOString().replace(/\.\d{3}Z$/, 'Z')
// 期限 = 登録日時 + 優先度ごとの時間
export const dueAt = (createdAt: string, priority: Priority) =>
toDbTime(new Date(new Date(createdAt).getTime() + SLA_HOURS[priority] * 3600_000))
// 1回の定期実行で扱う上限。キューの sendBatch は1回100件まで
const LIMIT = 100
type OverdueRow = { id: number; priority: Priority; created_at: string }
// 期限を過ぎていて、まだ知らせていないチケットを探す
export const findOverdue = async (env: Env, now: Date): Promise<OverdueRow[]> => {
const cutoff = (p: Priority) => toDbTime(new Date(now.getTime() - SLA_HOURS[p] * 3600_000))
const { results } = await env.DB.prepare(
`SELECT t.id, t.priority, t.created_at
FROM tickets t
LEFT JOIN sla_alerts a ON a.ticket_id = t.id
WHERE t.status IN ('open', 'in_progress')
AND a.ticket_id IS NULL
AND ((t.priority = 'high' AND t.created_at <= ?)
OR (t.priority = 'normal' AND t.created_at <= ?)
OR (t.priority = 'low' AND t.created_at <= ?))
ORDER BY t.created_at
LIMIT ${LIMIT}`,
)
.bind(cutoff('high'), cutoff('normal'), cutoff('low'))
.all<OverdueRow>()
return results
}
// キューのうち、使う機能(まとめて預ける sendBatch)だけ。テストでは偽物に差し替える
type Sender = { sendBatch: (messages: { body: NotificationMessage }[]) => Promise<unknown> }
// 期限超過を見つけて通知をキューに預け、「知らせた」と記録する。預けた件数を返す
// 順番は「預ける → 記録する」。記録に失敗して次の回にもう一度預けても、
// 送信記録(notification_log)があるので同じ人に2通は届かない
export const detectOverdue = async (env: Env, now: Date, queue: Sender = env.NOTIFY_QUEUE) => {
const rows = await findOverdue(env, now)
if (rows.length === 0) return 0
await queue.sendBatch(rows.map((r) => ({ body: { kind: 'ticket_overdue', ticketId: r.id } as const })))
await env.DB.batch(
rows.map((r) =>
env.DB.prepare('INSERT OR IGNORE INTO sla_alerts (ticket_id, due_at, detected_at) VALUES (?, ?, ?)').bind(
r.id,
dueAt(r.created_at, r.priority),
toDbTime(now),
),
),
)
console.log(`期限超過を ${rows.length} 件見つけ、通知をキューに預けました`)
return rows.length
}
findOverdue の SQL は、LEFT JOIN(もう一方の表に対応する行が無くても残す結合)で sla_alerts とつなぎ、記録が無いもの(a.ticket_id IS NULL)だけを選んでいます。これが「見つける段階」の二重防止です。
3. 通知先のルールを足す
第6回で作った宛先のルールに、期限超過の通知先を足します。管理者を見分けるため、Staff に role を持たせます。src/recipients.ts に次の変更を加えます(+ が追加、- が削除です)。
--- a/src/recipients.ts
+++ b/src/recipients.ts
@@ -2,7 +2,7 @@
// 通知の宛先を決めるルール。DB には触らず、材料を受け取って宛先の一覧を返すだけにする
// (ルールを1か所にまとめ、テストで確かめやすくするため)
-export type Staff = { email: string }
+export type Staff = { email: string; role?: string }
export type CommentContext = {
author: string // コメントを書いた人
@@ -29,3 +29,11 @@ export const commentRecipients = (ctx: CommentContext): string[] => {
// 割り当ての通知先:新しい担当者だけ。自分で自分を割り当てたときは送らない
export const assignedRecipients = (ctx: { actor: string; assignee: string | null }): string[] =>
finish([ctx.assignee], ctx.actor)
+
+// 期限超過の通知先:担当者と管理者。未割り当てなら担当者・管理者の全員
+// (申請者には送らない。「遅れています」は社内で対応を決めてから伝えるため)
+export const overdueRecipients = (ctx: { assignee: string | null; staff: Staff[] }): string[] => {
+ const admins = ctx.staff.filter((s) => s.role === 'admin').map((s) => s.email)
+ const agents = ctx.assignee ? [ctx.assignee] : ctx.staff.map((s) => s.email)
+ return [...new Set([...agents, ...admins])]
+}
担当者がいれば「担当者+管理者」、未割り当てなら「担当者・管理者の全員」です。未割り当てのまま期限を過ぎたチケットは、誰かが拾う必要があるため、全員に知らせます。
4. 通知の処理に「期限超過」を足す
src/notify.ts に、メッセージの種類 ticket_overdue と、その宛先・文面を足します。キューで待っているあいだに解決されていたら送らないよう、送る直前にチケットの状態を確かめます。src/notify.ts に次の変更を加えます。
--- a/src/notify.ts
+++ b/src/notify.ts
@@ -1,6 +1,7 @@
// src/notify.ts
import { sendMail, type SendMail } from './mail'
-import { assignedRecipients, commentRecipients, type Staff } from './recipients'
+import { assignedRecipients, commentRecipients, overdueRecipients, type Staff } from './recipients'
+import { dueAt, type Priority } from './sla'
// キューに入れるメッセージ。チケットの中身は入れず、番号だけにする
// (本文や個人情報をキューに残さない。送る直前に D1 から最新を読む)
@@ -8,6 +9,7 @@ export type NotificationMessage =
| { kind: 'ticket_created'; ticketId: number }
| { kind: 'comment_added'; ticketId: number; commentId: number }
| { kind: 'ticket_assigned'; ticketId: number; eventId: number }
+ | { kind: 'ticket_overdue'; ticketId: number }
// 登録の流れ(API)から呼ぶ。キューに入れられなくても、チケットの登録自体は成功させる
export const enqueueNotification = async (env: Env, message: NotificationMessage) => {
@@ -28,9 +30,11 @@ type TicketRow = {
id: number
title: string
category: string
- priority: string
+ priority: Priority
+ status: string
requester: string
assignee: string | null
+ created_at: string
}
// 1件の通知で「誰に・何を送るか」。logKind は送信記録(notification_log)の kind に入れる値
@@ -41,7 +45,7 @@ const FOOTER = '\n\n詳細はヘルプデスクの画面で確認してくださ
// メッセージの種類ごとに、宛先と文面を決める。送る必要がなければ null
const buildNotice = async (env: Env, message: NotificationMessage, ticket: TicketRow): Promise<Notice | null> => {
const { results: staff } = await env.DB.prepare(
- `SELECT email FROM users WHERE role IN ('agent', 'admin') AND active = 1 ORDER BY id`,
+ `SELECT email, role FROM users WHERE role IN ('agent', 'admin') AND active = 1 ORDER BY id`,
).all<Staff>()
const title = oneLine(ticket.title)
@@ -89,13 +93,22 @@ const buildNotice = async (env: Env, message: NotificationMessage, ticket: Ticke
text: `割り当てた人: ${event.actor}\nカテゴリ: ${ticket.category}\n優先度: ${ticket.priority}${FOOTER}`,
}
}
+ case 'ticket_overdue':
+ // キューで待っているあいだに解決・完了していたら送らない
+ if (ticket.status !== 'open' && ticket.status !== 'in_progress') return null
+ return {
+ logKind: 'ticket_overdue',
+ recipients: overdueRecipients({ assignee: ticket.assignee, staff }),
+ subject: `[ヘルプデスク] 期限を過ぎています #${ticket.id}: ${title}`,
+ text: `期限: ${dueAt(ticket.created_at, ticket.priority)}(UTC)\n優先度: ${ticket.priority}\n担当者: ${ticket.assignee ?? '未割り当て'}${FOOTER}`,
+ }
}
}
// 1件の通知を処理する。送れなかった宛先があれば、最後にエラーを投げて再試行にまわす
export const handleMessage = async (env: Env, message: NotificationMessage, send: SendMail) => {
const ticket = await env.DB.prepare(
- 'SELECT id, title, category, priority, requester, assignee FROM tickets WHERE id = ?',
+ 'SELECT id, title, category, priority, status, requester, assignee, created_at FROM tickets WHERE id = ?',
)
.bind(message.ticketId)
.first<TicketRow>()
送信記録の logKind は ticket_overdue で固定にしています。1件のチケットにつき、期限超過のメールは1人1通です。
5. Cron Triggers を設定し、scheduled の入口を足す
wrangler.jsonc に、15分ごとに動く設定を足します。
--- a/wrangler.jsonc
+++ b/wrangler.jsonc
@@ -9,6 +9,8 @@
"MAIL_FROM": "helpdesk@example.com",
"MAIL_API_URL": "https://mail-api.example.com/v1/send"
},
+ // 15分ごとに期限超過を調べる(時刻は UTC)
+ "triggers": { "crons": ["*/15 * * * *"] },
"queues": {
"producers": [{ "binding": "NOTIFY_QUEUE", "queue": "helpdesk-notifications" }],
"consumers": [
src/index.ts の default export に scheduled を足します。第5回で fetch と queue をまとめた場所です。
--- a/src/index.ts
+++ b/src/index.ts
@@ -6,6 +6,7 @@ import { tickets } from './routes/tickets'
import { attachments } from './routes/attachments'
import { comments } from './routes/comments'
import { processBatch, type NotificationMessage } from './notify'
+import { detectOverdue } from './sla'
const app = new Hono<AppEnv>()
@@ -27,8 +28,12 @@ app.route('/api', comments)
export { app }
-// Workers は fetch(HTTP)と queue(キューの受け取り)を1つの default export にまとめる
+// Workers は fetch(HTTP)・queue(キューの受け取り)・scheduled(定期実行)を1つの default export にまとめる
export default {
fetch: app.fetch,
queue: (batch: MessageBatch<NotificationMessage>, env: Env) => processBatch(batch, env),
+ // scheduledTime は「本来動くはずだった時刻」。遅れて動いても、この時刻を基準に判定する
+ scheduled: async (controller: ScheduledController, env: Env) => {
+ await detectOverdue(env, new Date(controller.scheduledTime))
+ },
} satisfies ExportedHandler<Env, NotificationMessage>
controller.scheduledTime は、その定期実行が「本来動くはずだった時刻」(1970年1月1日からのミリ秒)です。実際の起動が少し遅れても、判定の基準がずれません。また、wrangler.jsonc を変えたので、型の定義を作り直しておきます。
📰 出典:Cloudflare Docs「Scheduled Handler」
npx wrangler types
6. テストで確かめる
時刻を固定して判定できるよう、detectOverdue は「今」の時刻とキューを引数で受け取る形にしてあります。テストでは偽のキューを渡し、預けたメッセージを覚えておきます。確かめるのは次のことです。
- 期限ちょうどの前後(4時間1分たった/3時間59分)で、超過かどうかが分かれる
- 解決済みのチケットは対象外
- 次の定期実行で、同じチケットを二度預けない
- キューに預けられなかったときは記録せず、次の回でやり直せる
- 通知先と、2回処理しても1人1通になること
- キューで待っているあいだに解決されたら送らない
<details> <summary>test/sla.test.ts の全文</summary>
// test/sla.test.ts
import { env } from 'cloudflare:test'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import type { Mail } from '../src/mail'
import { handleMessage } from '../src/notify'
import { overdueRecipients } from '../src/recipients'
import { detectOverdue, dueAt } from '../src/sla'
import { loginAs } from './helpers'
beforeAll(async () => {
await loginAs('taro@example.com', 'employee')
await loginAs('agent@example.com', 'agent')
await loginAs('agent2@example.com', 'agent')
await loginAs('admin@example.com', 'admin')
})
// 基準の時刻。テストでは「今」を固定して判定する
const NOW = new Date('2026-11-14T12:00:00Z')
// 登録日時を指定してチケットを作る
const ticketAt = async (createdAt: string, priority: 'high' | 'normal' | 'low', status = 'open', assignee: string | null = null) => {
const row = await env.DB.prepare(
`INSERT INTO tickets (title, body, category, priority, status, requester, assignee, created_at, updated_at)
VALUES ('期限テスト', '本文', 'pc', ?, ?, 'taro@example.com', ?, ?, ?) RETURNING id`,
)
.bind(priority, status, assignee, createdAt, createdAt)
.first<{ id: number }>()
return row!.id
}
// キューに預けたメッセージを覚えておく偽のキュー
const fakeQueue = () => {
const sent: unknown[] = []
const sendBatch = vi.fn(async (msgs: Iterable<{ body: unknown }>) => {
for (const m of msgs) sent.push(m.body)
})
return { sent, queue: { sendBatch } }
}
describe('期限の計算', () => {
it('優先度ごとに 高4時間・通常24時間・低72時間', () => {
expect(dueAt('2026-11-14T00:00:00Z', 'high')).toBe('2026-11-14T04:00:00Z')
expect(dueAt('2026-11-14T00:00:00Z', 'normal')).toBe('2026-11-15T00:00:00Z')
expect(dueAt('2026-11-14T00:00:00Z', 'low')).toBe('2026-11-17T00:00:00Z')
})
})
describe('期限超過の検知(定期実行)', () => {
it('期限を過ぎた未解決のチケットだけをキューに預ける', async () => {
const highLate = await ticketAt('2026-11-14T07:59:00Z', 'high') // 4時間1分たった
const highOk = await ticketAt('2026-11-14T08:01:00Z', 'high') // まだ3時間59分
const normalLate = await ticketAt('2026-11-13T11:00:00Z', 'normal', 'in_progress')
const lowOk = await ticketAt('2026-11-13T11:00:00Z', 'low') // 低は72時間まで待つ
const resolved = await ticketAt('2026-11-01T00:00:00Z', 'high', 'resolved') // 解決済みは対象外
const { sent, queue } = fakeQueue()
await detectOverdue(env, NOW, queue)
const ids = sent.map((m) => (m as { ticketId: number }).ticketId)
expect(ids).toEqual(expect.arrayContaining([highLate, normalLate]))
expect(ids).not.toContain(highOk)
expect(ids).not.toContain(lowOk)
expect(ids).not.toContain(resolved)
expect(sent).toContainEqual({ kind: 'ticket_overdue', ticketId: highLate })
})
it('同じチケットは、次の定期実行で二度預けない', async () => {
const id = await ticketAt('2026-11-10T00:00:00Z', 'normal')
const first = fakeQueue()
await detectOverdue(env, NOW, first.queue)
expect(first.sent).toContainEqual({ kind: 'ticket_overdue', ticketId: id })
const second = fakeQueue()
await detectOverdue(env, new Date(NOW.getTime() + 15 * 60_000), second.queue)
expect(second.sent).not.toContainEqual({ kind: 'ticket_overdue', ticketId: id })
const alert = await env.DB.prepare('SELECT due_at FROM sla_alerts WHERE ticket_id = ?').bind(id).first<{ due_at: string }>()
expect(alert?.due_at).toBe('2026-11-11T00:00:00Z')
})
it('キューに預けられなかったときは記録せず、次の定期実行でやり直す', async () => {
const id = await ticketAt('2026-11-09T00:00:00Z', 'normal')
const broken = { sendBatch: () => Promise.reject(new Error('queue down')) }
await expect(detectOverdue(env, NOW, broken)).rejects.toThrow('queue down')
const retry = fakeQueue()
await detectOverdue(env, NOW, retry.queue)
expect(retry.sent).toContainEqual({ kind: 'ticket_overdue', ticketId: id })
})
})
describe('期限超過の通知メール', () => {
it('宛先:担当者がいれば担当者と管理者、未割り当てなら担当者・管理者の全員', () => {
const staff = [
{ email: 'agent@example.com', role: 'agent' },
{ email: 'agent2@example.com', role: 'agent' },
{ email: 'admin@example.com', role: 'admin' },
]
expect(overdueRecipients({ assignee: 'agent2@example.com', staff })).toEqual(['agent2@example.com', 'admin@example.com'])
expect(overdueRecipients({ assignee: null, staff }).sort()).toEqual(['admin@example.com', 'agent2@example.com', 'agent@example.com'])
})
it('申請者には送らず、2回処理しても同じ人に2通は届かない', async () => {
const id = await ticketAt('2026-11-10T00:00:00Z', 'high', 'in_progress', 'agent2@example.com')
const sent: Mail[] = []
const send = async (_env: Env, mail: Mail) => void sent.push(mail)
await handleMessage(env, { kind: 'ticket_overdue', ticketId: id }, send)
await handleMessage(env, { kind: 'ticket_overdue', ticketId: id }, send)
expect(sent.map((m) => m.to).sort()).toEqual(['admin@example.com', 'agent2@example.com'])
expect(sent[0]!.subject).toContain('期限を過ぎています')
expect(sent[0]!.text).toContain('2026-11-10T04:00:00Z')
})
it('キューで待っているあいだに解決されたら送らない', async () => {
const id = await ticketAt('2026-11-10T00:00:00Z', 'high', 'resolved')
const send = vi.fn()
await handleMessage(env, { kind: 'ticket_overdue', ticketId: id }, send)
expect(send).not.toHaveBeenCalled()
})
})
</details>
実行は、これまでと同じコマンドです。
npm run typecheck
npm test
筆者の環境(Node.js 22)では、型チェックが通り、テストは8ファイル61件がすべて成功しました(開発環境での確認です)。
動作確認:ローカルで定期実行を呼び出す
ローカルの wrangler dev では、定期実行は自動では動きません。代わりに、特別な URL を呼ぶと scheduled を1回動かせます。time に時刻(ミリ秒)を渡すと、その時刻に動いたことにできます。
npx wrangler d1 migrations apply helpdesk --local
npx wrangler d1 execute helpdesk --local --command "INSERT INTO tickets (title, body, category, priority, requester, created_at) VALUES ('VPNにつながらない', '本文', 'pc', 'high', 'taro@example.com', '2026-11-14T00:00:00Z')"
npx wrangler dev
別のターミナルで、登録から6時間後(2026-11-14T06:00:00Z)に動いたことにして呼び出します。
curl "http://localhost:8787/cdn-cgi/local/scheduled?cron=*/15+*+*+*+*&time=1794636000000&format=json"
npx wrangler d1 execute helpdesk --local --command "SELECT * FROM sla_alerts"
筆者の環境では、{"outcome":"ok"} が返り、wrangler dev のログに「期限超過を 1 件見つけ、通知をキューに預けました」と出ました。sla_alerts には、期限 2026-11-14T04:00:00Z(優先度「高」で登録から4時間)、検知 2026-11-14T06:00:00Z の1行が入りました。同じ URL をもう一度呼んでも、行は増えず、ログにも検知の行は出ませんでした。
📰 出典:Cloudflare Docs「Cron Triggers – Test Cron Triggers locally」
実際の Cloudflare 環境で定期実行が動くこと、実在のメール送信サービスへの送信は、この環境では確認できていません(未確認)。Cloudflare の資料では、cron の設定を変えてから全体に行き渡るまで最大15分ほどかかり、そのあいだの定期実行は動かないことがあると書かれています。
つまずきやすい点と注意
- 時刻は UTC:cron 式は UTC で動きます。「毎朝9時(日本時間)に一覧を送る」なら
0 0 * * *です。期限の表示も UTC なので、メールに日本時間で出したい場合は表示のときに変換します - 営業時間で数えたい場合:期限の計算(
dueAt)を「営業日と営業時間の表」を使う形に差し替えます。祝日・年末年始・会社独自の休みをどこで管理するかを先に決めておきます - 再オープンしたチケット:この回の作りでは、1件のチケットにつき期限超過の通知は1回です。「解決」から「対応中」に戻ったときに期限を数え直すかは、運用で決めて作り分けます
- 無料プランの制限:執筆時点(2026年11月)の Cloudflare の資料では、Cron Triggers は無料プランで1アカウント5個まで、1回の CPU 時間は10ミリ秒までです。この回の処理は D1 とキューの待ち時間が主なので収まる見込みですが、件数が多い場合は有料プランを前提に考えます
- 通知が多すぎると読まれなくなる:期限超過が毎日何十件も出るなら、期限の設定か人員の配置が実態に合っていません。メールを増やすより、まず期限の見直しを検討します
発注者向けメモ:期限管理・リマインドを開発会社に頼むときの確認点
「期限を過ぎたら知らせる」は短く言える機能ですが、決めごとの量で工数が大きく変わります。依頼するときは次の点を確認しておくと安心です。
- ☐ 期限は何から何までの時間か(登録から「最初の返信」まで/「解決」まで、あるいは両方)
- ☐ 経過時間で数えるか、営業時間で数えるか。営業時間なら、休日の表は誰がどこで更新するか
- ☐ 期限の長さは優先度ごとにいくつか。あとから管理画面で変えたいか
- ☐ 誰に、何回知らせるか(1回だけか、期限を過ぎてから一定時間ごとに繰り返すか、上長にも上げるか)
- ☐ 期限超過の件数を、月ごとの集計として見たいか
開発会社への質問例です。
- 「定期実行が何かの理由で動かなかった回があった場合、取りこぼしはどうなりますか?」
- 「同じチケットについて、期限超過のメールが何度も届かないことをどう確かめていますか?」
工数やリスクが増えやすいのは、「営業時間・祝日を考慮したい」「段階的に上長へ知らせたい(エスカレーション)」「チャットツールにも通知したい」「期限を顧客との契約(外部向けの SLA)として扱いたい」といった要望です。外部向けの SLA は、違反したときの扱いが契約の話になるため、システムの作りとは別に、契約の内容を専門家と確認しておく必要があります。金額の目安は、依頼先の見積もりで確認してください。
まとめと次回予告
この回では、優先度ごとに対応期限を決め、Cron Triggers で15分ごとに期限超過を探し、前回までのキューを使って担当者と管理者に知らせるところまで作りました。ポイントは次の3つです。
- 定期実行の処理は、時刻とキューを引数で受け取る形にして、テストで時刻を固定する
- 二重通知は「見つける段階(sla_alerts)」と「送る段階(notification_log)」の2段階で防ぐ
- 順番は「キューに預ける → 記録する」。失敗したら次の回にやり直せる
次回(第8回)は「管理画面を Hono の JSX で作る」です。ここまで API だけだったヘルプデスクに、サーバー側で描画する画面を付け、フォームと CSRF(ほかのサイトから勝手に操作を送られる攻撃)への対策を扱います。
この連載の記事一覧
この記事は連載「Honoで作る社内ヘルプデスク」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【Honoで作る社内ヘルプデスク 第0回】連載の全体像と、Hono+Cloudflare Workersの雛形を動かす
- 【Honoで作る社内ヘルプデスク 第1回】チケット登録APIを作る:D1のマイグレーションとZodの入力チェック
- 【Honoで作る社内ヘルプデスク 第2回】チケットの一覧と状態遷移を作る:ページングと「戻せない状態」のルール
- 【Honoで作る社内ヘルプデスク 第3回】ログインと権限を付ける:パスワードの保存・Cookie・「社員/担当者/管理者」の線引き
- 【Honoで作る社内ヘルプデスク 第4回】添付ファイルを R2 に保存する:種類・大きさの制限とダウンロード権限
- 【Honoで作る社内ヘルプデスク 第5回】通知メールをCloudflare Queuesで非同期に送る
- 【Honoで作る社内ヘルプデスク 第6回】コメントと担当者の割り当て、変更の履歴を残す
- 【Honoで作る社内ヘルプデスク 第7回】期限超過をCron Triggersで検知する:対応期限(SLA)と二重通知の防ぎ方(この記事)








