MENU

問い合わせ


    【Honoで作る社内ヘルプデスク 第2回】チケットの一覧と状態遷移を作る:ページングと「戻せない状態」のルール

    前回の第1回では、問い合わせを受け付けて保存する「チケット登録API」を作りました。今回は、溜まったチケットを一覧で見て、担当者が状態を進めていく部分を作ります。

    (2026年10月29日 追記:記事どおりに再現できるよう、必要なコードと手順を追加しました)

    前回の記事はこちらです。

    「問い合わせが増えてきて、誰が何を対応中なのか分からなくなってきた。完了したはずの件が、いつの間にか未対応に戻っていることもある…」

    先に結論です。一覧は「件数を決めて区切って返す」、状態は「進められる順番をコードで1か所に決めておく」。この2点を押さえておくと、チケットが数千件に増えても画面が重くならず、「完了が勝手に未対応へ戻る」といった事故も防げます。

    この回で作るのは次の3つです。

    1. チケット一覧API(新しい順・状態で絞り込み・ページング)
    2. 状態の遷移ルール(受付→対応中→解決→完了)
    3. 状態変更API(ルール違反と、同時に操作したときの衝突を 409 で返す)

    ※ 今回のコードは、Node.js 22系・Hono 4・Zod 4・Wrangler 4・TypeScript 6 で、型チェックとテスト(Cloudflare Workers の実行環境 workerd 上で実行)が通ることを確認しています。Cloudflare の実環境へのデプロイは、この回でも未確認です。

    目次

    仕組み:一覧と状態遷移で決めること

    一覧:返す件数を必ず制限する

    チケットが1万件あるのに全件を一度に返すと、通信も画面表示も遅くなります。そこで、1ページあたりの件数(perPage)と、何ページ目か(page)を受け取って区切って返すようにします。

    項目内容
    status状態での絞り込み(省略すると全件)
    pageページ番号(1から。省略時は1)
    perPage1ページの件数(1〜100。省略時は20)

    perPage に上限(100)を付けるのが大事なところです。上限がないと perPage=100000 と指定されて、実質的に全件取得できてしまいます。

    状態:「どこからどこへ進めるか」を表にする

    今回の状態は次の4つです。

    状態意味次に進める状態
    open受付(未対応)対応中
    in_progress対応中解決
    resolved解決(利用者の確認待ち)完了、または対応中に戻す
    closed完了なし

    「解決」から「対応中」に戻せるのは、「直ったと思ったが、直っていなかった」という現場でよくある状況に対応するためです。一方、「完了」は最終状態とし、戻せないようにします。完了後の再発は、新しいチケットとして受け付けるほうが履歴が分かりやすくなります。

    実装

    第1回までのプロジェクトに続けて作業します。新しく作るファイルは全文を、書き換えるファイルは変更箇所(差分)を載せています。

    1. 遷移ルールを1か所にまとめる

    まず、ルールそのものを書いた src/status.ts を、次の内容で作ります。

    // src/status.ts
    export const statuses = ['open', 'in_progress', 'resolved', 'closed'] as const
    export type Status = (typeof statuses)[number]
    
    // 状態の遷移ルール。「いまの状態 → 次に移れる状態」を1か所にまとめる
    // 解決(resolved)から対応中へ戻せる=「直っていなかった」を許す。完了(closed)からは戻せない
    export const transitions: Record<Status, readonly Status[]> = {
      open: ['in_progress'],
      in_progress: ['resolved'],
      resolved: ['closed', 'in_progress'],
      closed: [],
    }
    
    export const canTransition = (from: Status, to: Status): boolean => transitions[from].includes(to)

    ルールを if 文であちこちに散らさず、表(オブジェクト)1つにしているのがポイントです。「解決から完了の前に“確認済み”を挟みたい」といった変更が来ても、この表を直すだけで済みます。Record<Status, ...> と型を付けているので、状態を1つ増やしてこの表に書き忘れると、型チェックでエラーになります。

    2. 入力の検証ルール(Zod)

    次に、リクエストの検証を src/schemas.ts に足します。第1回で作った Zod の仕組みをそのまま使います。src/schemas.ts に次の変更を加えます(+ が追加、- が削除)。

    --- a/src/schemas.ts
    +++ b/src/schemas.ts
    @@ -1,5 +1,6 @@
     // src/schemas.ts
     import { z } from 'zod'
    +import { statuses } from './status'
     
     export const categories = ['pc', 'account', 'system', 'other'] as const
     export const priorities = ['low', 'normal', 'high'] as const
    @@ -14,3 +15,15 @@ export const createTicketSchema = z.object({
     })
     
     export type CreateTicketInput = z.infer<typeof createTicketSchema>
    +
    +// 状態の変更。変更後の状態だけを受け取る
    +export const changeStatusSchema = z.object({
    +  status: z.enum(statuses),
    +})
    +
    +// 一覧の絞り込みとページング。クエリ文字列は文字列で届くので coerce で数値にする
    +export const listQuerySchema = z.object({
    +  status: z.enum(statuses).optional(),
    +  page: z.coerce.number().int().min(1).default(1),
    +  perPage: z.coerce.number().int().min(1).max(100).default(20),
    +})

    URLの ?page=2 のような値は、すべて文字列として届きます。z.coerce.number() は、文字列を数値に変換してから検証します。perPage の .max(100) が、前述の上限です。

    3. 一覧API:件数と一覧を1回でまとめて取る

    src/routes/tickets.ts に一覧を追加します。次の変更を加えます(+ が追加、- が削除)。先頭の import には、このあと手順4で使う部品もまとめて足しています。

    --- a/src/routes/tickets.ts
    +++ b/src/routes/tickets.ts
    @@ -1,7 +1,8 @@
     // src/routes/tickets.ts
     import { zValidator } from '@hono/zod-validator'
     import { Hono } from 'hono'
    -import { createTicketSchema } from '../schemas'
    +import { changeStatusSchema, createTicketSchema, listQuerySchema } from '../schemas'
    +import { canTransition, type Status } from '../status'
     
     export const tickets = new Hono<{ Bindings: Env }>()
     
    @@ -40,3 +41,29 @@ tickets.get('/:id{[0-9]+}', async (c) => {
       if (!ticket) return c.json({ error: 'not_found' }, 404)
       return c.json(ticket)
     })
    +
    +// 一覧。新しい順。status で絞り込み、page / perPage で区切る(総件数も返す)
    +tickets.get(
    +  '/',
    +  zValidator('query', listQuerySchema, (result, c) => {
    +    if (!result.success) return c.json({ error: 'invalid_request' }, 400)
    +  }),
    +  async (c) => {
    +    const { status, page, perPage } = c.req.valid('query')
    +    const where = status ? 'WHERE status = ?' : ''
    +    const filter = status ? [status] : []
    +
    +    // 件数と一覧を1回の往復でまとめて取る
    +    const [count, list] = await c.env.DB.batch([
    +      c.env.DB.prepare(`SELECT COUNT(*) AS total FROM tickets ${where}`).bind(...filter),
    +      c.env.DB.prepare(
    +        `SELECT id, title, category, priority, status, requester, created_at
    +         FROM tickets ${where}
    +         ORDER BY created_at DESC, id DESC
    +         LIMIT ? OFFSET ?`,
    +      ).bind(...filter, perPage, (page - 1) * perPage),
    +    ])
    +    const total = (count.results[0] as { total: number }).total
    +    return c.json({ items: list.results, page, perPage, total })
    +  },
    +)

    ここでの工夫は3つです。

    • batch() で2つのSQLを1回の往復で実行:D1 の batch() は複数のSQLをまとめて送る機能です。件数取得と一覧取得を別々に呼ぶより、待ち時間が短くなります。
    • where に埋め込んでいるのは固定の文字列だけ:利用者が入力した値(status の中身)は bind() で渡しており、SQLの文字列には連結していません。第1回で触れたSQLインジェクション対策と同じ考え方です。
    • ORDER BY created_at DESC, id DESC:同じ時刻に作られたチケットがあっても、順番が毎回変わらないように id も並べ替えの条件に入れています。

    出典は Cloudflare 公式ドキュメントです(確認:2026年10月)。

    📰 出典:Cloudflare Docs「D1 Database API(batch)」

    4. 状態変更API:ルール違反と「同時に変更」を弾く

    最後に、状態を変えるAPIです。src/routes/tickets.ts の末尾に、次のとおり追加します(+ が追加)。

    --- a/src/routes/tickets.ts
    +++ b/src/routes/tickets.ts
    @@ -67,3 +67,35 @@ tickets.get(
         return c.json({ items: list.results, page, perPage, total })
       },
     )
    +
    +// 状態の変更。ルールにない遷移は 409、すでに他の人が変えていた場合も 409
    +tickets.patch(
    +  '/:id{[0-9]+}/status',
    +  zValidator('json', changeStatusSchema, (result, c) => {
    +    if (!result.success) return c.json({ error: 'invalid_request' }, 400)
    +  }),
    +  async (c) => {
    +    const id = Number(c.req.param('id'))
    +    const { status: next } = c.req.valid('json')
    +
    +    const current = await c.env.DB.prepare('SELECT status FROM tickets WHERE id = ?')
    +      .bind(id)
    +      .first<{ status: Status }>()
    +    if (!current) return c.json({ error: 'not_found' }, 404)
    +    if (!canTransition(current.status, next)) {
    +      return c.json({ error: 'invalid_transition', from: current.status, to: next }, 409)
    +    }
    +
    +    // 「読んだときの状態のまま」のときだけ更新する。同時に別の人が変えていたら 0 件になる
    +    const updated = await c.env.DB.prepare(
    +      `UPDATE tickets
    +       SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
    +       WHERE id = ? AND status = ?
    +       RETURNING id, status, updated_at`,
    +    )
    +      .bind(next, id, current.status)
    +      .first()
    +    if (!updated) return c.json({ error: 'conflict' }, 409)
    +    return c.json(updated)
    +  },
    +)

    見てほしいのは、UPDATE の WHERE id = ? AND status = ? です。

    担当者AとBが同じチケットを同時に開いていて、Aが「解決」にした直後にBが「対応中」の画面から「解決」を押すと、本来は「すでに解決済み」のはずです。更新の条件に「さっき読んだときの状態のまま」を入れておくと、後から来た操作は0件更新になり、409(衝突)として弾けます。 「先に読んで、あとで書く」間にデータが変わっていないかを確かめるこの方法は、一般に楽観的ロックと呼ばれます。ロックで待たせない分、軽く動きます。

    409 Conflict は、「リクエストの形は正しいが、いまの状態と矛盾する」ときのHTTPステータスです。入力の形が悪い 400 と使い分けておくと、画面側が「入力し直してください」と「最新の状態を読み直してください」を出し分けられます。

    動作確認

    テストはこれまでと同じ npm test です。今回は次の観点を12件に増やしました。

    • 一覧が総件数・ページ番号つきで返り、perPage で区切れる
    • status で絞り込める。不正な値や perPage=1000 は 400
    • 受付→対応中→解決→完了と進められる
    • 受付から解決へ飛ばすと 409、解決から対応中へは戻せる
    • 完了したチケットは動かせない。存在しないIDは 404

    テストは、第1回で作った test/tickets.test.ts の末尾に追加します。次の変更を加えます(+ が追加)。

    --- a/test/tickets.test.ts
    +++ b/test/tickets.test.ts
    @@ -51,3 +51,67 @@ describe('チケット登録 API', () => {
         expect(res.status).toBe(404)
       })
     })
    +
    +const create = async (over: Record<string, unknown> = {}) => {
    +  const res = await post({ ...valid, ...over })
    +  return (await res.json<{ id: number }>()).id
    +}
    +const patchStatus = (id: number, status: string) =>
    +  SELF.fetch(`http://example.com/api/tickets/${id}/status`, {
    +    method: 'PATCH',
    +    headers: { 'content-type': 'application/json' },
    +    body: JSON.stringify({ status }),
    +  })
    +
    +describe('チケット一覧 API', () => {
    +  it('総件数・ページ番号つきで返り、perPage で区切れる', async () => {
    +    for (let i = 0; i < 3; i++) await create({ title: `一覧テスト${i}` })
    +    const res = await SELF.fetch('http://example.com/api/tickets?perPage=2&page=1')
    +    expect(res.status).toBe(200)
    +    const json = await res.json<{ items: unknown[]; total: number; page: number }>()
    +    expect(json.items).toHaveLength(2)
    +    expect(json.total).toBeGreaterThanOrEqual(3)
    +    expect(json.page).toBe(1)
    +  })
    +
    +  it('status で絞り込める。不正な値・範囲外の perPage は 400', async () => {
    +    const id = await create()
    +    await patchStatus(id, 'in_progress')
    +    const res = await SELF.fetch('http://example.com/api/tickets?status=in_progress')
    +    const json = await res.json<{ items: { status: string }[] }>()
    +    expect(json.items.length).toBeGreaterThan(0)
    +    expect(json.items.every((t) => t.status === 'in_progress')).toBe(true)
    +
    +    expect((await SELF.fetch('http://example.com/api/tickets?status=foo')).status).toBe(400)
    +    expect((await SELF.fetch('http://example.com/api/tickets?perPage=1000')).status).toBe(400)
    +  })
    +})
    +
    +describe('状態の遷移', () => {
    +  it('受付→対応中→解決→完了と進められる', async () => {
    +    const id = await create()
    +    expect((await patchStatus(id, 'in_progress')).status).toBe(200)
    +    expect((await patchStatus(id, 'resolved')).status).toBe(200)
    +    const res = await patchStatus(id, 'closed')
    +    expect(res.status).toBe(200)
    +    expect((await res.json<{ status: string }>()).status).toBe('closed')
    +  })
    +
    +  it('受付から解決へ飛ばすのは 409、解決から対応中へは戻せる', async () => {
    +    const id = await create()
    +    const skip = await patchStatus(id, 'resolved')
    +    expect(skip.status).toBe(409)
    +    expect(await skip.json()).toMatchObject({ error: 'invalid_transition', from: 'open', to: 'resolved' })
    +
    +    await patchStatus(id, 'in_progress')
    +    await patchStatus(id, 'resolved')
    +    expect((await patchStatus(id, 'in_progress')).status).toBe(200)
    +  })
    +
    +  it('完了したチケットは動かせない。存在しない ID は 404', async () => {
    +    const id = await create()
    +    for (const s of ['in_progress', 'resolved', 'closed']) await patchStatus(id, s)
    +    expect((await patchStatus(id, 'in_progress')).status).toBe(409)
    +    expect((await patchStatus(999999, 'in_progress')).status).toBe(404)
    +  })
    +})

    次のコマンドで、型チェックとテストを行います。

    npx wrangler types
    npm run typecheck
    npm test

    あわせて、npx wrangler dev で起動し、ローカルのD1に対して curl でも確認しました(第1回の curl で、チケットを1件登録してから試します)。

    # 受付のまま「解決」へ飛ばす → 409
    curl -s -X PATCH localhost:8787/api/tickets/1/status \
      -H 'content-type: application/json' -d '{"status":"resolved"}'
    # {"error":"invalid_transition","from":"open","to":"resolved"}
    
    # 「対応中」へ進める → 200
    curl -s -X PATCH localhost:8787/api/tickets/1/status \
      -H 'content-type: application/json' -d '{"status":"in_progress"}'

    つまずきやすい点・省略したこと

    • ページ番号方式の弱点:OFFSET は、後ろのページほど「読み飛ばす行数」が増えて遅くなります。数万件を超える場合は、最後に見たIDを基準に次を取る方式(カーソル方式)も検討します。社内ヘルプデスクの規模であれば、まずはページ番号方式で十分です。
    • 「誰が変えたか」の履歴はまだ残していません:状態の変更履歴は、第6回(コメントと担当者の割り当て)で扱います。
    • 認証がまだありません:今は誰でも状態を変えられます。ログインと権限は第3回で追加します。この段階のAPIを、そのままインターネットに公開してはいけません。
    • Cloudflare の実環境は未確認:ローカルのD1(wrangler dev)とテスト環境での確認です。

    発注者向けメモ:状態遷移の「ルール」は最初に決めておく

    画面の見た目に比べて後回しにされがちですが、状態遷移のルールは、運用の食い違いが最も出やすい部分です。開発会社に頼むときは、次の点を確認しておくと手戻りを減らせます。

    発注者がやることチェックリスト

    • ☐ 自社の対応の流れを「受付→対応中→…」と言葉で書き出した(状態の名前と意味を決めた)
    • ☐ 「一度完了したものを再び開けるか」を決めた(再発は新規チケットにするか、再オープンを許すか)
    • ☐ 「誰が状態を変えられるか」を決めた(担当者だけか、依頼者も完了にできるか)
    • ☐ 対応の履歴(いつ・誰が・何に変えたか)を後から見る必要があるか確認した
    • ☐ 件数が増えたときの上限の目安(年間何件くらいか)を伝えた

    開発会社への質問例

    • 「状態の種類と、状態から状態への進め方のルールを、画面を作る前に一覧表で見せてもらえますか?」
    • 「同じチケットを複数の担当者が同時に操作したとき、どう扱いますか?」
    • 「状態を変えた履歴は残りますか?残す場合、費用や工数はどのくらい変わりますか?」
    • 「状態の種類を後から増やしたいとき、どの範囲を直す必要がありますか?」

    状態を後から増やす・ルールを変える作業は、データの持ち方にも影響するため、早い段階で決めておくほど安く済みます。

    まとめと次回予告

    今回は、チケットの一覧(絞り込み・ページング)と、状態の遷移ルール、同時操作の衝突を弾く状態変更APIを作りました。ポイントは、一覧は件数に上限を付けて区切る、状態の進め方は表1つにまとめる、更新の条件に「読んだときの状態」を入れるの3つです。

    次回の第3回では、ログインと権限(社員・担当者・管理者)を付けます。今回「誰でも変えられる」状態のAPIに、役割ごとの制限をかけていきます。

    システムの発注や、社内ツールの構成選びでお悩みの方は、お気軽にご相談ください。

    この連載の記事一覧

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

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


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

      この記事を書いた人

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

      コメント

      目次