MENU

問い合わせ

    お問い合わせ


    【Honoで作る社内ヘルプデスク 第7回】期限超過をCron Triggersで検知する:対応期限(SLA)と二重通知の防ぎ方

    社内ヘルプデスクでは、問い合わせが「受け付けたまま誰も触っていない」状態になるのが一番の困りごとです。担当者が休みだった、優先度の高いものが埋もれていた、といった理由で、気づいたときには数日たっていた、ということが起こります。

    「急ぎの問い合わせが放置されていないか、毎朝一覧を見て確かめるのが大変。期限を過ぎたら自動で知らせてほしい」

    結論から言うと、優先度ごとに対応期限(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回渡したときの重複

    この回で作るものの全体像

    1. マイグレーションで、知らせた記録の表 sla_alerts を足す
    2. 期限の計算と期限超過の検知を src/sla.ts にまとめる
    3. 期限超過の通知先のルールを src/recipients.ts に足す
    4. 通知の処理 src/notify.ts に「期限超過」の種類を足す
    5. wrangler.jsonc に Cron Triggers を設定し、src/index.ts に scheduled の入口を足す
    6. テストで、期限の判定・二重通知の防止・通知先を確かめる

    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 とキューの待ち時間が主なので収まる見込みですが、件数が多い場合は有料プランを前提に考えます

    📰 出典:Cloudflare Docs「Limits」

    • 通知が多すぎると読まれなくなる:期限超過が毎日何十件も出るなら、期限の設定か人員の配置が実態に合っていません。メールを増やすより、まず期限の見直しを検討します

    発注者向けメモ:期限管理・リマインドを開発会社に頼むときの確認点

    「期限を過ぎたら知らせる」は短く言える機能ですが、決めごとの量で工数が大きく変わります。依頼するときは次の点を確認しておくと安心です。

    • ☐ 期限は何から何までの時間か(登録から「最初の返信」まで/「解決」まで、あるいは両方)
    • ☐ 経過時間で数えるか、営業時間で数えるか。営業時間なら、休日の表は誰がどこで更新するか
    • ☐ 期限の長さは優先度ごとにいくつか。あとから管理画面で変えたいか
    • ☐ 誰に、何回知らせるか(1回だけか、期限を過ぎてから一定時間ごとに繰り返すか、上長にも上げるか)
    • ☐ 期限超過の件数を、月ごとの集計として見たいか

    開発会社への質問例です。

    • 「定期実行が何かの理由で動かなかった回があった場合、取りこぼしはどうなりますか?」
    • 「同じチケットについて、期限超過のメールが何度も届かないことをどう確かめていますか?」

    工数やリスクが増えやすいのは、「営業時間・祝日を考慮したい」「段階的に上長へ知らせたい(エスカレーション)」「チャットツールにも通知したい」「期限を顧客との契約(外部向けの SLA)として扱いたい」といった要望です。外部向けの SLA は、違反したときの扱いが契約の話になるため、システムの作りとは別に、契約の内容を専門家と確認しておく必要があります。金額の目安は、依頼先の見積もりで確認してください。

    まとめと次回予告

    この回では、優先度ごとに対応期限を決め、Cron Triggers で15分ごとに期限超過を探し、前回までのキューを使って担当者と管理者に知らせるところまで作りました。ポイントは次の3つです。

    • 定期実行の処理は、時刻とキューを引数で受け取る形にして、テストで時刻を固定する
    • 二重通知は「見つける段階(sla_alerts)」と「送る段階(notification_log)」の2段階で防ぐ
    • 順番は「キューに預ける → 記録する」。失敗したら次の回にやり直せる

    次回(第8回)は「管理画面を Hono の JSX で作る」です。ここまで API だけだったヘルプデスクに、サーバー側で描画する画面を付け、フォームと CSRF(ほかのサイトから勝手に操作を送られる攻撃)への対策を扱います。

    この連載の記事一覧

    この記事は連載「Honoで作る社内ヘルプデスク」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら

      お問い合わせ


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      目次