MENU

問い合わせ

    お問い合わせ


    【Honoで作る社内ヘルプデスク 第6回】コメントと担当者の割り当て、変更の履歴を残す

    社内ヘルプデスクに問い合わせが届いても、「誰が対応するのか」が決まっていないと、担当者どうしの譲り合いや二重対応が起きます。やり取りがメールや口頭に散らばると、後から「いつ・誰が・何を決めたか」も追えなくなります。

    「問い合わせのやり取りをチケットの中にまとめたい。担当が決まったら本人に知らせて、後から経緯も追えるようにしたい」

    結論から言うと、担当者はチケットに1人だけ持たせ、コメントと変更の記録は別の表に「追記だけ」で残し、通知は前回作ったキューに任せるのが扱いやすい形です。この回では、Hono と Cloudflare D1 で、コメント(社員に見せない「社内メモ」つき)・担当者の割り当て・変更の履歴を作り、誰に通知するかのルールを1か所にまとめます。

    前回は、チケットの登録を担当者へメールで知らせる仕組みを、Cloudflare Queues で作りました。まだの方は先にご覧ください。

    詳しくは前回の記事で解説しています。

    この回のコードは、前回までのプロジェクトに足していきます。新しいライブラリのインストールは不要です。

    目次

    コメントと担当者の割り当てで決めておく3つのこと

    作り始める前に、次の3つを決めておくと、後で直す量が減ります。

    決めることこの連載での決め方理由
    担当者は何人かチケットごとに1人(未割り当ては空)「誰がボールを持っているか」をはっきりさせるため
    誰が何を見られるか社員は自分のチケットの通常のコメントだけ。担当者・管理者は社内メモも見られる社内の調査メモや他部署の事情を、申請者にそのまま見せないため
    変更をどう残すか状態の変更と担当者の変更を、別の表に追記だけで残す上書きすると「前はどうだったか」が消えるため

    履歴の残し方には、大きく2つの考え方があります。

    • 上書き型:チケットの行に「最終更新者」「更新日時」だけを持つ。作りは簡単ですが、途中の経緯は残りません
    • 追記型(イベントログ):変更のたびに「誰が・いつ・何を・何から何へ」を1行足す。行は更新も削除もしません

    ヘルプデスクでは「担当が誰から誰に移ったか」「いつ解決にしたか」を後から確かめたい場面が多いため、追記型にします。

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

    1. マイグレーション(データベースの変更を順番に当てる仕組み)で、担当者の列・コメントの表・履歴の表を足す
    2. 通知の宛先を決めるルールを src/recipients.ts にまとめる
    3. 通知の処理 src/notify.ts を、コメントと割り当てにも対応させる
    4. コメント・割り当て・履歴の API を src/routes/comments.ts に作る
    5. 状態の変更(第2回)でも履歴を残す
    6. テストで、見える範囲・通知先・同時更新を確かめる
    操作APIできる人通知先
    コメント一覧GET /api/tickets/:id/comments申請者本人・担当者・管理者(社員には社内メモを出さない)なし
    コメントを書くPOST /api/tickets/:id/comments同上(社内メモは担当者・管理者だけ)下の表のとおり
    担当者の割り当てPATCH /api/tickets/:id/assignee担当者・管理者新しい担当者
    変更の履歴GET /api/tickets/:id/history申請者本人・担当者・管理者なし

    1. 担当者・コメント・履歴の表を作る

    次のファイルを新しく作ります。

    -- migrations/0006_comments_and_assignee.sql
    -- 第6回: 担当者の割り当て・コメント・変更の履歴
    -- 担当者は requester と同じくメールアドレスで持つ(未割り当ては NULL)
    ALTER TABLE tickets ADD COLUMN assignee TEXT;
    
    -- コメント。internal = 1 は担当者・管理者だけが見られる「社内メモ」
    CREATE TABLE IF NOT EXISTS comments (
      id          INTEGER PRIMARY KEY AUTOINCREMENT,
      ticket_id   INTEGER NOT NULL REFERENCES tickets (id),
      author      TEXT NOT NULL,
      body        TEXT NOT NULL,
      internal    INTEGER NOT NULL DEFAULT 0 CHECK (internal IN (0, 1)),
      created_at  TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
    );
    CREATE INDEX IF NOT EXISTS idx_comments_ticket ON comments (ticket_id, id);
    
    -- 変更の履歴。追記だけを行い、更新・削除はしない(誰が・いつ・何を・何から何へ)
    CREATE TABLE IF NOT EXISTS ticket_events (
      id          INTEGER PRIMARY KEY AUTOINCREMENT,
      ticket_id   INTEGER NOT NULL REFERENCES tickets (id),
      actor       TEXT NOT NULL,
      type        TEXT NOT NULL CHECK (type IN ('status_changed', 'assigned')),
      from_value  TEXT,
      to_value    TEXT,
      created_at  TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
    );
    CREATE INDEX IF NOT EXISTS idx_ticket_events_ticket ON ticket_events (ticket_id, id);
    • tickets に assignee(担当者のメールアドレス)を足します。申請者(requester)と同じく、メールアドレスで持ちます
    • comments の internal が 1 のものは社内メモです
    • ticket_events は履歴の表です。type は今のところ「状態の変更(status_changed)」と「担当者の割り当て(assigned)」の2種類だけにし、CHECK 制約で決まった値しか入らないようにしています

    ローカルのデータベースに当てます。

    npx wrangler d1 migrations apply helpdesk --local

    2. 通知の宛先のルールを1か所にまとめる

    「コメントが付いたら誰に知らせるか」は、運用の途中で一番変わりやすいところです。そこで、データベースには触らず、材料を受け取って宛先を返すだけの関数にまとめます。ルールを変えるときはこのファイルだけを見れば済み、テストも書きやすくなります。

    出来事通知先
    社員がコメントした担当者(未割り当てなら担当者・管理者の全員)
    担当者・管理者がコメントした申請者と担当者(書いた本人を除く)
    社内メモが付いた担当者だけ(未割り当てなら担当者・管理者の全員)。申請者には送らない
    担当者を割り当てた新しい担当者(自分で自分を割り当てたときは送らない)

    次のファイルを新しく作ります。

    // src/recipients.ts
    // 通知の宛先を決めるルール。DB には触らず、材料を受け取って宛先の一覧を返すだけにする
    // (ルールを1か所にまとめ、テストで確かめやすくするため)
    
    export type Staff = { email: string }
    
    export type CommentContext = {
      author: string // コメントを書いた人
      internal: boolean // 社内メモか
      requester: string // 申請した社員
      assignee: string | null // 担当者(未割り当てなら null)
      staff: Staff[] // 有効な担当者・管理者の全員
    }
    
    // 重複を除き、書いた本人を外す(自分の操作で自分にメールが来ないようにする)
    const finish = (emails: (string | null)[], actor: string) =>
      [...new Set(emails.filter((e): e is string => !!e))].filter((e) => e !== actor)
    
    // コメントの通知先
    // - 社内メモ:担当者だけ(未割り当てなら担当者・管理者の全員)。申請者には絶対に送らない
    // - 通常のコメント:申請者と、担当者(未割り当てなら担当者・管理者の全員)
    export const commentRecipients = (ctx: CommentContext): string[] => {
      const staffEmails = ctx.staff.map((s) => s.email)
      const agents = ctx.assignee ? [ctx.assignee] : staffEmails
      if (ctx.internal) return finish(agents.filter((e) => staffEmails.includes(e)), ctx.author)
      return finish([ctx.requester, ...agents], ctx.author)
    }
    
    // 割り当ての通知先:新しい担当者だけ。自分で自分を割り当てたときは送らない
    export const assignedRecipients = (ctx: { actor: string; assignee: string | null }): string[] =>
      finish([ctx.assignee], ctx.actor)

    社内メモの宛先は、担当者・管理者の一覧(staff)に入っている人だけに絞っています。仮に何かの理由で申請者が担当者の欄に入っていても、社内メモが社員に届かないようにするための二重の守りです。

    3. 通知の処理をコメントと割り当てに広げる

    前回の src/notify.ts は「新しい問い合わせ」の1種類だけでした。これを、メッセージの種類ごとに「宛先と文面」を決める buildNotice と、実際に送る部分に分けます。

    ポイントは次の3つです。

    • キューには番号だけを入れる方針はそのまま:コメントなら commentId、割り当てなら履歴の eventId を入れ、送る直前に D1 から読みます
    • 送信記録の種類に番号を含める:前回の notification_log は「チケット・種類・宛先」の組で二重送信を防いでいました。コメントは1つのチケットに何件も付くので、種類を comment_added:15 のようにコメントの番号つきにして、別の通知として数えます
    • 停止された人には送らない:退職などで active = 0 にした人は、宛先から外します

    src/notify.ts に次の変更を加えます(+ が追加、- が削除です)。

    <details> <summary>src/notify.ts の変更(差分)</summary>

    --- a/src/notify.ts
    +++ b/src/notify.ts
    @@ -1,9 +1,13 @@
     // src/notify.ts
     import { sendMail, type SendMail } from './mail'
    +import { assignedRecipients, commentRecipients, type Staff } from './recipients'
    
     // キューに入れるメッセージ。チケットの中身は入れず、番号だけにする
     // (本文や個人情報をキューに残さない。送る直前に D1 から最新を読む)
    -export type NotificationMessage = { kind: 'ticket_created'; ticketId: number }
    +export type NotificationMessage =
    +  | { kind: 'ticket_created'; ticketId: number }
    +  | { kind: 'comment_added'; ticketId: number; commentId: number }
    +  | { kind: 'ticket_assigned'; ticketId: number; eventId: number }
    
     // 登録の流れ(API)から呼ぶ。キューに入れられなくても、チケットの登録自体は成功させる
     export const enqueueNotification = async (env: Env, message: NotificationMessage) => {
    @@ -20,41 +24,107 @@ export const retryDelaySeconds = (attempts: number) => Math.min(30 * 2 ** (attem
     // ヘッダーの行を壊す改行を取り除く
     const oneLine = (s: string) => s.replace(/[\r\n]+/g, ' ').trim()
    
    -type TicketRow = { id: number; title: string; category: string; priority: string; requester: string }
    +type TicketRow = {
    +  id: number
    +  title: string
    +  category: string
    +  priority: string
    +  requester: string
    +  assignee: string | null
    +}
    +
    +// 1件の通知で「誰に・何を送るか」。logKind は送信記録(notification_log)の kind に入れる値
    +type Notice = { logKind: string; recipients: string[]; subject: string; text: string }
    +
    +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`,
    +  ).all<Staff>()
    +  const title = oneLine(ticket.title)
    +
    +  switch (message.kind) {
    +    case 'ticket_created':
    +      return {
    +        logKind: 'ticket_created',
    +        recipients: staff.map((s) => s.email),
    +        subject: `[ヘルプデスク] 新しい問い合わせ #${ticket.id}: ${title}`,
    +        text: `カテゴリ: ${ticket.category}\n優先度: ${ticket.priority}\n申請者: ${ticket.requester}${FOOTER}`,
    +      }
    +    case 'comment_added': {
    +      const comment = await env.DB.prepare('SELECT author, internal FROM comments WHERE id = ? AND ticket_id = ?')
    +        .bind(message.commentId, ticket.id)
    +        .first<{ author: string; internal: number }>()
    +      if (!comment) return null
    +      const internal = comment.internal === 1
    +      return {
    +        // 同じチケットにコメントは何件も付くので、コメントの番号まで含めて記録する
    +        logKind: `comment_added:${message.commentId}`,
    +        recipients: commentRecipients({
    +          author: comment.author,
    +          internal,
    +          requester: ticket.requester,
    +          assignee: ticket.assignee,
    +          staff,
    +        }),
    +        subject: `[ヘルプデスク] #${ticket.id} に${internal ? '社内メモ' : 'コメント'}が付きました: ${title}`,
    +        // コメントの本文はメールに載せない(問い合わせの内容はログイン後の画面で見せる)
    +        text: `書いた人: ${comment.author}${FOOTER}`,
    +      }
    +    }
    +    case 'ticket_assigned': {
    +      const event = await env.DB.prepare(
    +        `SELECT actor, to_value FROM ticket_events WHERE id = ? AND ticket_id = ? AND type = 'assigned'`,
    +      )
    +        .bind(message.eventId, ticket.id)
    +        .first<{ actor: string; to_value: string | null }>()
    +      if (!event) return null
    +      return {
    +        logKind: `ticket_assigned:${message.eventId}`,
    +        // 割り当てたときの担当者に送る。その後に担当が変わっていても、この割り当ての通知として扱う
    +        recipients: assignedRecipients({ actor: event.actor, assignee: event.to_value }),
    +        subject: `[ヘルプデスク] #${ticket.id} の担当になりました: ${title}`,
    +        text: `割り当てた人: ${event.actor}\nカテゴリ: ${ticket.category}\n優先度: ${ticket.priority}${FOOTER}`,
    +      }
    +    }
    +  }
    +}
    
     // 1件の通知を処理する。送れなかった宛先があれば、最後にエラーを投げて再試行にまわす
     export const handleMessage = async (env: Env, message: NotificationMessage, send: SendMail) => {
    -  const ticket = await env.DB.prepare('SELECT id, title, category, priority, requester FROM tickets WHERE id = ?')
    +  const ticket = await env.DB.prepare(
    +    'SELECT id, title, category, priority, requester, assignee FROM tickets WHERE id = ?',
    +  )
         .bind(message.ticketId)
         .first<TicketRow>()
       // チケットが無ければ、何度やっても送れない。再試行せず終わらせる
       if (!ticket) return
    +  const notice = await buildNotice(env, message, ticket)
    +  if (!notice) return
    
    -  const { results: agents } = await env.DB.prepare(
    -    `SELECT email FROM users WHERE role IN ('agent', 'admin') AND active = 1 ORDER BY id`,
    -  ).all<{ email: string }>()
    +  // 停止(active=0)された人には送らない
    +  const { results: active } = await env.DB.prepare('SELECT email FROM users WHERE active = 1').all<{ email: string }>()
    +  const activeEmails = new Set(active.map((u) => u.email))
    
       let failed = 0
    -  for (const { email } of agents) {
    +  for (const email of notice.recipients.filter((e) => activeEmails.has(e))) {
         // すでに送った相手には送らない(キューは同じ通知を2回渡すことがあるため)
         const done = await env.DB.prepare(
           'SELECT 1 AS ok FROM notification_log WHERE ticket_id = ? AND kind = ? AND recipient = ?',
         )
    -      .bind(ticket.id, message.kind, email)
    +      .bind(ticket.id, notice.logKind, email)
           .first()
         if (done) continue
         try {
    -      await send(env, {
    -        to: email,
    -        subject: `[ヘルプデスク] 新しい問い合わせ #${ticket.id}: ${oneLine(ticket.title)}`,
    -        text: `カテゴリ: ${ticket.category}\n優先度: ${ticket.priority}\n申請者: ${ticket.requester}\n\n詳細はヘルプデスクの画面で確認してください。`,
    -      })
    +      await send(env, { to: email, subject: notice.subject, text: notice.text })
           await env.DB.prepare('INSERT OR IGNORE INTO notification_log (ticket_id, kind, recipient) VALUES (?, ?, ?)')
    -        .bind(ticket.id, message.kind, email)
    +        .bind(ticket.id, notice.logKind, email)
             .run()
         } catch (e) {
           failed++
    -      console.error('通知メールを送れませんでした', { ticketId: ticket.id, kind: message.kind }, e)
    +      console.error('通知メールを送れませんでした', { ticketId: ticket.id, kind: notice.logKind }, e)
         }
       }
       if (failed > 0) throw new Error(`${failed} 件の宛先に送れませんでした`)

    </details>

    コメントの通知メールには、コメントの本文を載せません。前回と同じく、問い合わせの中身はログイン後の画面で見せる作りにしています。メールは転送や誤送信で社外に出ることがあるためです。

    switch の message.kind は TypeScript の判別可能なユニオン型(kind の値で形が決まる型)なので、case 'comment_added' の中では message.commentId が使えます。種類を足したのに case を書き忘れると、戻り値の型が合わず型チェックで気づけます。

    4. コメント・割り当て・履歴の API を作る

    入力チェックの形を src/schemas.ts に足します。

    --- a/src/schemas.ts
    +++ b/src/schemas.ts
    @@ -25,3 +25,14 @@ export const listQuerySchema = z.object({
       page: z.coerce.number().int().min(1).default(1),
       perPage: z.coerce.number().int().min(1).max(100).default(20),
     })
    +
    +// コメント。internal(社内メモ)を付けられるのは担当者・管理者だけ(ルート側で確かめる)
    +export const createCommentSchema = z.object({
    +  body: z.string().trim().min(1, 'コメントを入力してください').max(2000, 'コメントは2000文字以内です'),
    +  internal: z.boolean().default(false),
    +})
    +
    +// 担当者の割り当て。null で「未割り当て」に戻す
    +export const assignSchema = z.object({
    +  assignee: z.string().trim().toLowerCase().email().max(254).nullable(),
    +})

    API 本体は次のファイルです。長いので、全文は折りたたんでいます。先に工夫を説明します。

    • 見えないチケットは 404:第3回と同じく、他人のチケットは「存在しない」と同じ扱いにします
    • 社内メモは SQL の条件で外す:社員が一覧を取るときは AND internal = 0 を付けます。画面側で隠すのではなく、API が返さないことが大切です
    • 社員は社内メモを書けない(403)、完了(closed)したチケットにはコメントできない(409)
    • 割り当て先は、有効な担当者・管理者だけ(400)
    • 割り当ての更新と履歴の追記を、同じトランザクションで行う:D1 の batch() は、渡した SQL をまとめて1つのトランザクション(全部成功するか、全部取り消すかのまとまり)として実行します。2つ目の SQL の WHERE changes() = 1 は「直前の UPDATE で1行変わったときだけ追記する」という条件です

    最後の点は、第2回で使った楽観的ロック(読んだときの値のまま変わっていなければ更新する方式)の続きです。読んだ後に別の担当者が割り当てを変えていたら、UPDATE は0行になり、changes() も0なので履歴も足されません。その場合は 409 を返し、通知もしません。

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

    📰 出典:SQLite「Built-In Scalar SQL Functions(changes)」

    <details> <summary>src/routes/comments.ts の全文</summary>

    // src/routes/comments.ts
    import { zValidator } from '@hono/zod-validator'
    import { Hono } from 'hono'
    import { requireLogin, requireRole, type AppEnv, type SessionUser } from '../auth'
    import { enqueueNotification } from '../notify'
    import { assignSchema, createCommentSchema } from '../schemas'
    
    export const comments = new Hono<AppEnv>()
    
    comments.use('*', requireLogin)
    
    type TicketRow = { id: number; status: string; requester: string; assignee: string | null }
    
    const isStaff = (user: SessionUser) => user.role !== 'employee'
    
    // チケットが存在し、見てよい人(本人または担当者・管理者)なら返す。それ以外は null(呼び出し側で 404)
    const visibleTicket = async (env: Env, user: SessionUser, id: number) => {
      const ticket = await env.DB.prepare('SELECT id, status, requester, assignee FROM tickets WHERE id = ?')
        .bind(id)
        .first<TicketRow>()
      if (!ticket) return null
      if (!isStaff(user) && ticket.requester !== user.email) return null
      return ticket
    }
    
    // コメント一覧。社員には社内メモを見せない(SQL の条件で外し、画面側の判断に頼らない)
    comments.get('/tickets/:id{[0-9]+}/comments', async (c) => {
      const user = c.get('user')
      const ticket = await visibleTicket(c.env, user, Number(c.req.param('id')))
      if (!ticket) return c.json({ error: 'not_found' }, 404)
      const { results } = await c.env.DB.prepare(
        `SELECT id, author, body, internal, created_at FROM comments
         WHERE ticket_id = ? ${isStaff(user) ? '' : 'AND internal = 0'}
         ORDER BY id`,
      )
        .bind(ticket.id)
        .all()
      return c.json({ items: results })
    })
    
    // コメントを書く。完了(closed)したチケットには書けない。書いたら関係者に通知する
    comments.post(
      '/tickets/:id{[0-9]+}/comments',
      zValidator('json', createCommentSchema, (result, c) => {
        if (!result.success) return c.json({ error: 'invalid_request' }, 400)
      }),
      async (c) => {
        const user = c.get('user')
        const input = c.req.valid('json')
        const ticket = await visibleTicket(c.env, user, Number(c.req.param('id')))
        if (!ticket) return c.json({ error: 'not_found' }, 404)
        if (input.internal && !isStaff(user)) return c.json({ error: 'forbidden' }, 403)
        if (ticket.status === 'closed') return c.json({ error: 'ticket_closed' }, 409)
    
        const comment = await c.env.DB.prepare(
          `INSERT INTO comments (ticket_id, author, body, internal) VALUES (?, ?, ?, ?)
           RETURNING id, author, body, internal, created_at`,
        )
          .bind(ticket.id, user.email, input.body, input.internal ? 1 : 0)
          .first<{ id: number }>()
        await enqueueNotification(c.env, { kind: 'comment_added', ticketId: ticket.id, commentId: comment!.id })
        return c.json(comment, 201)
      },
    )
    
    // 担当者の割り当て(担当者・管理者だけ)。割り当てられるのは、有効な担当者・管理者だけ
    comments.patch(
      '/tickets/:id{[0-9]+}/assignee',
      requireRole('agent', 'admin'),
      zValidator('json', assignSchema, (result, c) => {
        if (!result.success) return c.json({ error: 'invalid_request' }, 400)
      }),
      async (c) => {
        const user = c.get('user')
        const { assignee } = c.req.valid('json')
        const ticket = await visibleTicket(c.env, user, Number(c.req.param('id')))
        if (!ticket) return c.json({ error: 'not_found' }, 404)
        if (assignee === ticket.assignee) return c.json({ id: ticket.id, assignee })
    
        if (assignee) {
          const ok = await c.env.DB.prepare(
            `SELECT 1 AS ok FROM users WHERE email = ? AND role IN ('agent', 'admin') AND active = 1`,
          )
            .bind(assignee)
            .first()
          if (!ok) return c.json({ error: 'invalid_assignee' }, 400)
        }
    
        // 「読んだときの担当者のまま」なら更新し、同じトランザクションで履歴を1行足す。
        // 同時に別の人が変えていたら UPDATE は 0 件になり、changes() = 0 なので履歴も足されない
        const [, event] = await c.env.DB.batch([
          c.env.DB.prepare(
            `UPDATE tickets SET assignee = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
             WHERE id = ? AND assignee IS ?`,
          ).bind(assignee, ticket.id, ticket.assignee),
          c.env.DB.prepare(
            `INSERT INTO ticket_events (ticket_id, actor, type, from_value, to_value)
             SELECT ?, ?, 'assigned', ?, ? WHERE changes() = 1
             RETURNING id`,
          ).bind(ticket.id, user.email, ticket.assignee, assignee),
        ])
        const eventId = (event!.results[0] as { id: number } | undefined)?.id
        if (!eventId) return c.json({ error: 'conflict' }, 409)
    
        if (assignee) await enqueueNotification(c.env, { kind: 'ticket_assigned', ticketId: ticket.id, eventId })
        return c.json({ id: ticket.id, assignee })
      },
    )
    
    // 変更の履歴(状態の変更・担当者の割り当て)。古い順
    comments.get('/tickets/:id{[0-9]+}/history', async (c) => {
      const ticket = await visibleTicket(c.env, c.get('user'), Number(c.req.param('id')))
      if (!ticket) return c.json({ error: 'not_found' }, 404)
      const { results } = await c.env.DB.prepare(
        `SELECT id, actor, type, from_value, to_value, created_at FROM ticket_events
         WHERE ticket_id = ? ORDER BY id`,
      )
        .bind(ticket.id)
        .all()
      return c.json({ items: results })
    })

    </details>

    作ったルートを src/index.ts に登録します。

    --- a/src/index.ts
    +++ b/src/index.ts
    @@ -4,6 +4,7 @@ import { auth } from './routes/auth'
     import { users } from './routes/users'
     import { tickets } from './routes/tickets'
     import { attachments } from './routes/attachments'
    +import { comments } from './routes/comments'
     import { processBatch, type NotificationMessage } from './notify'
    
     const app = new Hono<AppEnv>()
    @@ -22,6 +23,7 @@ app.route('/api/auth', auth)
     app.route('/api/users', users)
     app.route('/api/tickets', tickets)
     app.route('/api', attachments)
    +app.route('/api', comments)
    
     export { app }
    

    5. 状態の変更でも履歴を残す

    第2回で作った状態の変更 API も、同じ形で履歴を残すようにします。一覧で担当者が見えるよう、assignee も返します。src/routes/tickets.ts に次の変更を加えます。

    --- a/src/routes/tickets.ts
    +++ b/src/routes/tickets.ts
    @@ -81,7 +81,7 @@ tickets.get(
         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
    +        `SELECT id, title, category, priority, status, requester, assignee, created_at
              FROM tickets ${where}
              ORDER BY created_at DESC, id DESC
              LIMIT ? OFFSET ?`,
    @@ -111,15 +111,21 @@ tickets.patch(
           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()
    +    // 「読んだときの状態のまま」のときだけ更新する。同時に別の人が変えていたら 0 件になる。
    +    // 更新できたときだけ(changes() = 1)、同じトランザクションで履歴を1行足す
    +    const [result] = await c.env.DB.batch([
    +      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),
    +      c.env.DB.prepare(
    +        `INSERT INTO ticket_events (ticket_id, actor, type, from_value, to_value)
    +         SELECT ?, ?, 'status_changed', ?, ? WHERE changes() = 1`,
    +      ).bind(id, c.get('user').email, current.status, next),
    +    ])
    +    const updated = result!.results[0]
         if (!updated) return c.json({ error: 'conflict' }, 409)
         return c.json(updated)
       },

    6. テストで確かめる

    テストは2つのファイルに分けました。宛先のルールはデータベースを使わずに確かめられるので、表の行ごとにテストを書いています。

    // test/recipients.test.ts
    import { describe, expect, it } from 'vitest'
    import { assignedRecipients, commentRecipients } from '../src/recipients'
    
    const staff = [{ email: 'agent@example.com' }, { email: 'agent2@example.com' }, { email: 'admin@example.com' }]
    const base = { requester: 'taro@example.com', staff }
    
    describe('コメントの通知先', () => {
      it('社員のコメント:担当者がいれば担当者だけに送る', () => {
        expect(
          commentRecipients({ ...base, author: 'taro@example.com', internal: false, assignee: 'agent@example.com' }),
        ).toEqual(['agent@example.com'])
      })
    
      it('社員のコメント:未割り当てなら担当者・管理者の全員に送る', () => {
        expect(commentRecipients({ ...base, author: 'taro@example.com', internal: false, assignee: null })).toEqual([
          'agent@example.com',
          'agent2@example.com',
          'admin@example.com',
        ])
      })
    
      it('担当者のコメント:申請者に送り、書いた本人には送らない', () => {
        expect(
          commentRecipients({ ...base, author: 'agent@example.com', internal: false, assignee: 'agent@example.com' }),
        ).toEqual(['taro@example.com'])
      })
    
      it('社内メモ:申請者には送らない', () => {
        const to = commentRecipients({ ...base, author: 'admin@example.com', internal: true, assignee: 'agent@example.com' })
        expect(to).toEqual(['agent@example.com'])
        expect(to).not.toContain('taro@example.com')
      })
    
      it('社内メモ:担当者が本人なら、だれにも送らない', () => {
        expect(
          commentRecipients({ ...base, author: 'agent@example.com', internal: true, assignee: 'agent@example.com' }),
        ).toEqual([])
      })
    })
    
    describe('割り当ての通知先', () => {
      it('新しい担当者に送る。自分で自分を割り当てたときは送らない', () => {
        expect(assignedRecipients({ actor: 'admin@example.com', assignee: 'agent@example.com' })).toEqual([
          'agent@example.com',
        ])
        expect(assignedRecipients({ actor: 'agent@example.com', assignee: 'agent@example.com' })).toEqual([])
        expect(assignedRecipients({ actor: 'admin@example.com', assignee: null })).toEqual([])
      })
    })

    API と通知を通したテストでは、確かめているのは次のとおりです。

    • 割り当てると履歴が残り、新しい担当者にだけ通知が行く
    • 社員は割り当てられない(403)、担当者でない人は割り当て先にできない(400)
    • 同じ担当者を割り当て直しても、履歴も通知も増えない。自分で自分を担当にしたらメールは送らない
    • 読んだ後に別の人が担当者を変えていたら 409 で、履歴も通知も増えない
    • 状態の変更の履歴が残り、ルール違反(409)のときは残らない
    • 社員のコメントは担当者へ、担当者のコメントは申請者へ通知され、本文はメールに載らない
    • 同じ通知が2回届いても1通だけ
    • 社内メモは社員に見えず、通知も行かず、社員は書けない
    • 他人のチケットは見ることも書くこともできない(404)。完了したチケットには書けない(409)
    • 停止された人には送らない

    「読んだ後に別の人が変えていた」状況は、テストの中でデータベースへの問い合わせを1か所だけ横取りして、チケットを読んだ直後に担当者を書き換えることで作っています。WHERE changes() = 1 を外すとこのテストが失敗することも確かめました。

    <details> <summary>test/comments.test.ts の全文</summary>

    // test/comments.test.ts
    import { env } from 'cloudflare:test'
    import { beforeAll, describe, expect, it, vi } from 'vitest'
    import { app } from '../src/index'
    import type { Mail } from '../src/mail'
    import { processBatch, type NotificationMessage } from '../src/notify'
    import { loginAs } from './helpers'
    
    let taro = ''
    let hanako = ''
    let agent = ''
    let admin = ''
    beforeAll(async () => {
      taro = await loginAs('taro@example.com', 'employee')
      hanako = await loginAs('hanako@example.com', 'employee')
      agent = await loginAs('agent@example.com', 'agent')
      admin = await loginAs('admin@example.com', 'admin')
    })
    
    // キューへの投入を横取りして、入ったメッセージを覚えておく
    const queued: NotificationMessage[] = []
    const testEnv = () => ({
      ...env,
      NOTIFY_QUEUE: { send: async (m: NotificationMessage) => void queued.push(m) },
    })
    
    const call = (path: string, cookie: string, method = 'GET', body?: unknown) =>
      app.request(
        path,
        {
          method,
          headers: { 'content-type': 'application/json', cookie },
          body: body === undefined ? undefined : JSON.stringify(body),
        },
        testEnv(),
      )
    
    const newTicket = async () => {
      const res = await call('/api/tickets', taro, 'POST', { title: 'VPNにつながらない', body: '在宅で接続できません', category: 'pc' })
      return (await res.json<{ id: number }>()).id
    }
    
    // キューに入ったメッセージを、偽のメール送信で処理して、送った宛先を返す
    const deliver = async (message: NotificationMessage) => {
      const sent: Mail[] = []
      const batch = {
        queue: 'helpdesk-notifications',
        messages: [{ id: 'm', attempts: 1, body: message, ack: vi.fn(), retry: vi.fn() }],
      } as unknown as MessageBatch<NotificationMessage>
      await processBatch(batch, env, async (_env, mail) => void sent.push(mail))
      return sent
    }
    
    describe('担当者の割り当て', () => {
      it('担当者を割り当てると履歴が残り、新しい担当者に通知が行く', async () => {
        const id = await newTicket()
        const res = await call(`/api/tickets/${id}/assignee`, admin, 'PATCH', { assignee: 'Agent@Example.com' })
        expect(res.status).toBe(200)
        expect(await res.json()).toEqual({ id, assignee: 'agent@example.com' })
    
        const history = await (await call(`/api/tickets/${id}/history`, taro)).json<{ items: unknown[] }>()
        expect(history.items).toMatchObject([
          { actor: 'admin@example.com', type: 'assigned', from_value: null, to_value: 'agent@example.com' },
        ])
    
        const message = queued.at(-1)!
        expect(message).toMatchObject({ kind: 'ticket_assigned', ticketId: id })
        const sent = await deliver(message)
        expect(sent.map((m) => m.to)).toEqual(['agent@example.com'])
        expect(sent[0]!.subject).toContain('担当になりました')
      })
    
      it('社員は割り当てられない(403)。担当者・管理者以外の人も割り当て先にできない(400)', async () => {
        const id = await newTicket()
        expect((await call(`/api/tickets/${id}/assignee`, taro, 'PATCH', { assignee: 'agent@example.com' })).status).toBe(403)
        expect((await call(`/api/tickets/${id}/assignee`, admin, 'PATCH', { assignee: 'hanako@example.com' })).status).toBe(400)
        expect((await call(`/api/tickets/${id}/assignee`, admin, 'PATCH', { assignee: 'nobody@example.com' })).status).toBe(400)
      })
    
      it('同じ担当者を割り当て直しても、履歴も通知も増えない', async () => {
        const id = await newTicket()
        await call(`/api/tickets/${id}/assignee`, admin, 'PATCH', { assignee: 'agent@example.com' })
        const before = queued.length
        const res = await call(`/api/tickets/${id}/assignee`, admin, 'PATCH', { assignee: 'agent@example.com' })
        expect(res.status).toBe(200)
        expect(queued.length).toBe(before)
        const history = await (await call(`/api/tickets/${id}/history`, admin)).json<{ items: unknown[] }>()
        expect(history.items).toHaveLength(1)
      })
    
      it('自分で自分を担当にしたときはメールを送らない。未割り当てに戻すと通知はしない', async () => {
        const id = await newTicket()
        await call(`/api/tickets/${id}/assignee`, agent, 'PATCH', { assignee: 'agent@example.com' })
        expect(await deliver(queued.at(-1)!)).toEqual([])
    
        const before = queued.length
        expect((await call(`/api/tickets/${id}/assignee`, agent, 'PATCH', { assignee: null })).status).toBe(200)
        expect(queued.length).toBe(before)
      })
      it('読んだ後に別の人が担当者を変えていたら 409 で、履歴も通知も増えない', async () => {
        const id = await newTicket()
        const before = queued.length
        // チケットを読んだ直後に、別の人が担当者を変えた状況を作る
        const db = new Proxy(env.DB, {
          get(target, prop) {
            if (prop !== 'prepare') return Reflect.get(target, prop)
            return (sql: string) => {
              const stmt = target.prepare(sql)
              if (!sql.startsWith('SELECT id, status, requester, assignee FROM tickets')) return stmt
              return {
                bind: (...args: unknown[]) => ({
                  first: async () => {
                    const row = await stmt.bind(...args).first()
                    await target.prepare('UPDATE tickets SET assignee = ? WHERE id = ?').bind('admin@example.com', id).run()
                    return row
                  },
                }),
              }
            }
          },
        })
        const res = await app.request(
          `/api/tickets/${id}/assignee`,
          { method: 'PATCH', headers: { 'content-type': 'application/json', cookie: agent }, body: JSON.stringify({ assignee: 'agent@example.com' }) },
          { ...testEnv(), DB: db },
        )
        expect(res.status).toBe(409)
        expect(queued.length).toBe(before)
        const history = await (await call(`/api/tickets/${id}/history`, admin)).json<{ items: unknown[] }>()
        expect(history.items).toHaveLength(0)
      })
    })
    
    describe('状態の変更の履歴', () => {
      it('状態を変えると、誰が・何から何へを履歴に残す。ルール違反(409)は残さない', async () => {
        const id = await newTicket()
        await call(`/api/tickets/${id}/status`, agent, 'PATCH', { status: 'in_progress' })
        expect((await call(`/api/tickets/${id}/status`, agent, 'PATCH', { status: 'closed' })).status).toBe(409)
        const history = await (await call(`/api/tickets/${id}/history`, agent)).json<{ items: unknown[] }>()
        expect(history.items).toMatchObject([
          { actor: 'agent@example.com', type: 'status_changed', from_value: 'open', to_value: 'in_progress' },
        ])
      })
    })
    
    describe('コメント', () => {
      it('社員がコメントすると、担当者に通知が行く(本文はメールに載せない)', async () => {
        const id = await newTicket()
        await call(`/api/tickets/${id}/assignee`, admin, 'PATCH', { assignee: 'agent@example.com' })
        const res = await call(`/api/tickets/${id}/comments`, taro, 'POST', { body: '再起動しても直りません' })
        expect(res.status).toBe(201)
    
        const message = queued.at(-1)!
        expect(message).toMatchObject({ kind: 'comment_added', ticketId: id })
        const sent = await deliver(message)
        expect(sent.map((m) => m.to)).toEqual(['agent@example.com'])
        expect(sent[0]!.text).not.toContain('再起動しても直りません')
      })
    
      it('担当者がコメントすると、申請者に通知が行く。同じ通知が2回届いても1通だけ', async () => {
        const id = await newTicket()
        await call(`/api/tickets/${id}/assignee`, admin, 'PATCH', { assignee: 'agent@example.com' })
        await call(`/api/tickets/${id}/comments`, agent, 'POST', { body: 'ルーターの再起動を試してください' })
        const message = queued.at(-1)!
        expect((await deliver(message)).map((m) => m.to)).toEqual(['taro@example.com'])
        expect(await deliver(message)).toEqual([])
      })
    
      it('社内メモは社員には見えず、通知も行かない。社員は社内メモを書けない', async () => {
        const id = await newTicket()
        await call(`/api/tickets/${id}/assignee`, admin, 'PATCH', { assignee: 'agent@example.com' })
        await call(`/api/tickets/${id}/comments`, admin, 'POST', { body: '同じ症状が他部署でも出ている', internal: true })
        expect((await deliver(queued.at(-1)!)).map((m) => m.to)).toEqual(['agent@example.com'])
    
        const forEmployee = await (await call(`/api/tickets/${id}/comments`, taro)).json<{ items: unknown[] }>()
        expect(forEmployee.items).toHaveLength(0)
        const forAgent = await (await call(`/api/tickets/${id}/comments`, agent)).json<{ items: unknown[] }>()
        expect(forAgent.items).toHaveLength(1)
    
        const res = await call(`/api/tickets/${id}/comments`, taro, 'POST', { body: 'メモ', internal: true })
        expect(res.status).toBe(403)
      })
    
      it('他人のチケットにはコメントできず、見ることもできない(404)', async () => {
        const id = await newTicket()
        expect((await call(`/api/tickets/${id}/comments`, hanako, 'POST', { body: '横から失礼します' })).status).toBe(404)
        expect((await call(`/api/tickets/${id}/comments`, hanako)).status).toBe(404)
        expect((await call(`/api/tickets/${id}/history`, hanako)).status).toBe(404)
      })
    
      it('完了したチケットにはコメントできない(409)。空のコメントは 400', async () => {
        const id = await newTicket()
        for (const status of ['in_progress', 'resolved', 'closed']) {
          await call(`/api/tickets/${id}/status`, agent, 'PATCH', { status })
        }
        expect((await call(`/api/tickets/${id}/comments`, taro, 'POST', { body: 'まだ直りません' })).status).toBe(409)
        expect((await call(`/api/tickets/${id}/comments`, agent, 'POST', { body: '  ' })).status).toBe(400)
      })
    
      it('停止された人には送らない', async () => {
        const id = await newTicket()
        await call(`/api/tickets/${id}/assignee`, admin, 'PATCH', { assignee: 'agent@example.com' })
        await call(`/api/tickets/${id}/comments`, agent, 'POST', { body: '確認します' })
        await env.DB.prepare('UPDATE users SET active = 0 WHERE email = ?').bind('taro@example.com').run()
        try {
          expect(await deliver(queued.at(-1)!)).toEqual([])
        } finally {
          await env.DB.prepare('UPDATE users SET active = 1 WHERE email = ?').bind('taro@example.com').run()
        }
      })
    })

    </details>

    実行は、これまでと同じコマンドです。

    npx wrangler types
    npm run typecheck
    npm test

    筆者の環境(Node.js 26 LTS)では、型チェックが通り、テストは7ファイル54件がすべて成功しました(開発環境での確認です)。

    動作確認:ローカルで割り当てからコメントまで通す

    前回と同じく、メール送信サービスの代わりに、受け取った内容を表示するだけの小さなサーバーを 127.0.0.1:8788 で動かし、wrangler dev をつなぎました。

    npx wrangler d1 migrations apply helpdesk --local
    npx wrangler dev --local --var MAIL_API_URL:http://127.0.0.1:8788/send

    管理者・担当者・社員の3人を用意し、次の順に操作しました。

    1. 社員がチケットを登録する
    2. 管理者が担当者を割り当てる(PATCH /api/tickets/1/assignee)
    3. 担当者が状態を「対応中」にし、コメントを書く
    4. 管理者が社内メモを書く

    偽のメールサーバーには、次の4種類の通知が届きました(宛先と件名だけを表示しています)。

    admin@example.com | [ヘルプデスク] 新しい問い合わせ #1: VPNにつながらない
    agent@example.com | [ヘルプデスク] 新しい問い合わせ #1: VPNにつながらない
    agent@example.com | [ヘルプデスク] #1 の担当になりました: VPNにつながらない
    taro@example.com | [ヘルプデスク] #1 にコメントが付きました: VPNにつながらない
    agent@example.com | [ヘルプデスク] #1 に社内メモが付きました: VPNにつながらない

    社員でコメント一覧を取ると、担当者のコメントだけが返り、社内メモは含まれませんでした。履歴には「管理者が担当者を割り当てた」「担当者が open から in_progress に変えた」の2行が残りました。

    実際の Cloudflare 環境へのデプロイと、実在のメール送信サービスへの送信は、この環境では確認できていません(未確認)。本番の D1 には、デプロイの前に npx wrangler d1 migrations apply helpdesk --remote でマイグレーションを当てる必要があります(第11回で扱います)。

    つまずきやすい点と注意

    • 社内メモは「書く人の注意」に頼らない:社内メモの欄があっても、間違えて通常のコメントで書くことはあります。申請者に見せたくない情報の扱いは、運用ルール(社内メモに書くもの・書かないもの)とセットで決めておきます
    • 履歴を消せる作りにしない:この回の API には、履歴を更新・削除する入口がありません。管理者でも消せないことが、記録の信頼性につながります。ただし、データベースを直接操作できる人は書き換えられます。改ざんまで防ぐ必要があるなら、ログを別の場所に送るなどの対策を検討します(第10回でログを扱います)
    • メールアドレスで人を表している:この連載では簡単にするため、申請者・担当者をメールアドレスで持っています。メールアドレスが変わる運用がある会社では、利用者の番号(users.id)で持つ形に変えるのが安全です
    • 通知の多さは使い勝手に直結する:コメントのたびに全員へメールが飛ぶと、通知が読まれなくなります。この回では「担当者が決まったら担当者だけ」「書いた本人には送らない」で数を抑えています

    発注者向けメモ:コメント・担当者まわりを開発会社に頼むときの確認点

    コメントと担当者の割り当ては、どのヘルプデスク・案件管理ツールにもある機能ですが、細かい決めごとで工数が大きく変わります。依頼するときは次の点を確認しておくと安心です。

    • ☐ 担当者は1人か、複数人(主担当・副担当など)か
    • ☐ 申請者に見せない社内メモが必要か。必要なら、誰が見られるか
    • ☐ 履歴として何を残すか(状態・担当者だけか、件名や優先度の変更も残すか)。履歴を消せる人はいるか
    • ☐ どの出来事で、誰に通知するか(一覧表にして合意しておく)
    • ☐ 退職・異動した担当者のチケットを、どう引き継ぐか

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

    • 「同じチケットを2人が同時に更新したとき、後から更新した人の内容はどうなりますか?」
    • 「申請者に見せない情報が、メールや画面に出てしまわないことを、どう確かめていますか?」

    工数やリスクが増えやすいのは、「担当者を複数にしたい」「部署ごとに見られる範囲を分けたい」「コメントの編集・削除をしたい(編集履歴も残したい)」「メール以外(チャットなど)にも通知したい」といった要望です。最初に範囲を決めておくと、見積もりのずれを防げます。金額の目安は、依頼先の見積もりで確認してください。

    まとめと次回予告

    この回では、チケットにコメントと担当者の割り当てを足し、変更の履歴を残し、前回のキューを使って関係者に通知するところまで作りました。ポイントは次の3つです。

    • 担当者はチケットに1人。社内メモは API の段階で社員に返さない
    • 状態と担当者の変更は、更新と同じトランザクションで履歴の表に追記だけで残す
    • 通知の宛先のルールは1つのファイルにまとめ、表の行ごとにテストする

    次回(第7回)は「期限超過を Cron Triggers で検知する」です。優先度ごとに対応期限(SLA)を決め、期限を過ぎたチケットを定期実行で見つけて担当者にリマインドします。今回の担当者の情報と通知の仕組みをそのまま使います。

    この連載の記事一覧

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

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

      お問い合わせ


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

      この記事を書いた人

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

      コメント

      目次