組織(org_id)ごとに独立したサポートチケット機能。専用の設定画面や外部連携(Zammadのような別システム)は持たず、常に2つのテーブルだけで完結する。
R1組織のメンバーは誰でも(Adminに限らない)、内部向けチケットを作成・閲覧・返信・クローズできる。
R2顧客(未ログイン・org所属なし)は、組織の公開ページや外部サイトに埋め込んだJSウィジェットからチケットを起票できる。
R3顧客は自分のチケットのpublic_token(128bitランダム値)を知っていれば、ログインなしで状況確認・追記返信ができる(2026-07-27改修: 旧来の「チケットID+メールアドレス」方式はIDOR、§9参照)。
R4System Monitor がアラートを検知した際、自動的にこのヘルプデスクへチケットを起票できる(§3-B参照)。
R5スタッフの返信は任意でメール通知として顧客に送信できる(Jinja2テンプレート、reply/transcript の2種)。
R6Kanban(Agile Task)からチケットを1件だけ参照リンクできる(緩い関連、外部キー制約なし)。
R7チケット一覧は組織単位で完全に分離される(他組織のチケットは一切見えない)。
R8公開エンドポイントは匿名の悪用(スパム起票・メール爆撃の踏み台化)に対してレート制限とボット対策(ハニーポット)を持つ(2026-07-27追加)。
R9新規チケット・顧客からの返信はスタッフへメール通知され、一覧に未読表示が出る(2026-07-27追加 — 従来は誰かがHelpdeskタブを開くまで誰も気づけなかった)。
3つの入口(内部UI / 公開ウィジェット / System Monitor)が同じ2テーブルに書き込み、同じ内部UI(Helpdeskタブ)から一元的に見える。バックグラウンドの巡回処理は無い(Monitorと違い、常時ポーリングするスレッドは持たない — すべてリクエスト駆動)。
backend/app/service/monitor/notifier/zammad.py はファイル名が示す外部Zammad連携ではなく、実体は custom_tickets への直接INSERTに置き換わっている(§8で詳述)。2026-07-27まではこの連携自体が機能していない不具合があったが、issue #34で修正済み(§8-5)。
状態は open / pending_customer / closed の3つのみ(TicketStatus Literal型)。pending_customer への遷移はスタッフ返信時に自動で起こり、フロントエンドから直接選べる状態ではない — 手動で選べるのは「close」「reopen」の2ボタンだけ。
Test ID対応(issue #54): helpdesk-ticket-close-button/helpdesk-ticket-reopen-button(TicketChat.tsx)は付与済み。顧客側の返信は埋め込みWidget側の操作(§6-1、安定したid属性 #archi-reply-btnで既に選択可能)。テストは全て未実装。
Test ID対応(issue #54): RL(レート制限)は埋め込みWidgetのsubmitボタン(#archi-submit-btn、安定した既存id)を連打して429を再現できるが専用test idは無い。HP(ハニーポット)はWidgetの隠しフィールド#archi-cust-websiteに値を入れて送信すれば偽装検知を再現できる — 本来ユーザーは触れない欄だがPlaywrightは容易に埋められる。SRC(widgetか否か)はWidgetの「Ticket Submitted!」成功画面と、確認メールのリンク先(GET /api/public/tickets/verify/{token}、サーバー側でレンダリングされる確認ページ)で観測可能。いずれもテストは未実装 — Widgetはこのアプリの通常のReact SPAとは別の埋め込みJS(helpdesk-widget.js)なので、Playwrightからはpage.addScriptTag等で別途読み込む必要がある(testid-spec.htmlのkebab-case規則はこのWidget自体には適用しない — 既存の安定したid属性をそのまま使う)。
Test ID対応(issue #54): スタッフ側はhelpdesk-reply-textarea/helpdesk-reply-send-button/helpdesk-reply-send-email-checkbox(全てTicketChat.tsx)を付与済み。S(送信成功/失敗)はhelpdesk-message-delivery-failed-banner(失敗時のみDOMに現れる)の有無で分岐を判定できる — SMTPを意図的に失敗させる設定が必要。helpdesk-message-retry-email-buttonは本仕様書自身が「手動でRetryを押してSMTP再送成功を確認済み」と記載している唯一の分岐だが、自動化されたPlaywrightテストはまだ無い。CV(顧客側の確認状態)はWidget側の分岐で、React側のtest idルールとは別に安定したid属性で選択可能。全分岐ともテストは未実装。
正規化された2テーブルのみ。専用の設定テーブルや添付ファイルテーブルは存在しない。2026-07-27のP1レビュー(issue #30)で custom_ticket_messages → custom_tickets の実FK、status/priority のCHECK制約、および複数の追跡用カラムを追加。
CREATE TABLE custom_tickets (
id SERIAL PRIMARY KEY,
org_id INTEGER NOT NULL REFERENCES organizations(id),
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
priority TEXT NOT NULL DEFAULT 'medium',
customer_name TEXT NOT NULL,
customer_email TEXT NOT NULL,
public_token TEXT NOT NULL UNIQUE,
-- 128bitランダム値。公開API認証の実体(旧: ID+email、
-- issue #25)。生成は uuid.uuid4().hex
source TEXT NOT NULL DEFAULT 'internal',
-- internal | widget | monitor
dedup_key TEXT,
-- Monitor起票のみ。同キーでstatus!='closed'のチケットが
-- あれば新規作成せず既存へ追記(issue #29)
occurrence_count INTEGER NOT NULL DEFAULT 1,
assignee_user_id UUID,
-- カラムのみ追加、担当者アサインUI自体は未実装(P2)
first_response_at TIMESTAMPTZ,
resolved_at TIMESTAMPTZ,
last_staff_viewed_at TIMESTAMPTZ,
-- 未読バッジ用(issue #28)
email_verified_at TIMESTAMPTZ,
-- 顧客がマジックリンクをクリックした時刻。NULL = 未確認
-- (issue #38)。内部作成(source='internal')は認証済み
-- スタッフ自身のメールなので作成時に即セット。移行前の
-- 既存行は created_at で一括バックフィル済み
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
CONSTRAINT chk_custom_tickets_status
CHECK (status IN ('open','pending_customer','closed')),
CONSTRAINT chk_custom_tickets_priority
CHECK (priority IN ('low','medium','high','urgent'))
-- Monitor由来の insert は severity をそのまま入れず
-- map_severity_to_priority()(critical→urgent)を経由
-- するため、このCHECKに違反しない(§8-3)
);
CREATE INDEX ON custom_tickets (org_id, created_at);
CREATE INDEX ON custom_tickets (org_id, status, updated_at);
CREATE INDEX ON custom_tickets (dedup_key);
CREATE TABLE custom_ticket_messages (
id SERIAL PRIMARY KEY,
ticket_id INTEGER NOT NULL
REFERENCES custom_tickets(id) ON DELETE CASCADE,
-- 2026-07-27まで実FKなし(アプリ側保証のみ)。issue #30で追加
org_id INTEGER NOT NULL,
-- 多層防御用。JOIN無しでスコープ確認できるよう追加(issue #30)
sender_name TEXT NOT NULL,
sender_email TEXT,
is_staff BOOLEAN NOT NULL DEFAULT FALSE,
message TEXT NOT NULL,
delivery_status TEXT NOT NULL DEFAULT 'none',
-- none | queued | sent | failed(issue #27)
delivery_error TEXT,
created_at TIMESTAMPTZ
);
CREATE INDEX ON custom_ticket_messages (ticket_id, created_at);
他機能からの緩い参照: agile_tasks.helpdesk_ticket_id(INTEGER NULL)がKanbanタスクから1件のチケットIDを保持できるが、外部キー制約は無い(@@index([helpdesk_ticket_id]) のみ)。UIはタスクカードのボタン押下時に sessionStorage 経由でHelpdeskタブへチケットIDを渡し、自動選択させる仕組み(§7参照)。チケット削除時に参照整合性チェックは行われない(このリンクは custom_tickets側のFK対象には含めていない — 意図的に緩いままの参照)。
organizations への追加カラム(issue #35): helpdesk_viewer_roles TEXT NOT NULL DEFAULT '' / helpdesk_manage_roles TEXT NOT NULL DEFAULT ''(カンマ区切りのロール名)。両方とも既定は空文字列で、その場合は全メンバーが従来通りフルアクセス(閲覧/作成/返信/close全て可能)— Adminが明示的に設定するまで挙動は変わらない。専用テーブルではなくorganizationsへの追加カラムにしたのは、Monitorのalert_recipient_rolesと同じ「カンマ区切りロール名」の既存パターンを踏襲したため(§8-3参照)。
| メソッド/パス | 説明 |
|---|---|
GET /api/tickets?cursor=&limit= |
自組織のチケット一覧、カーソルページネーション(updated_at降順、既定limit=50、最大200)。応答は {items, next_cursor}(issue #31 — 以前はdb.list_orgで無制限に全件返していた) |
POST /api/tickets |
内部チケット新規作成。customer_name/email は呼び出しユーザー自身の情報が自動で入る。source='internal'、public_tokenを新規発行 |
GET /api/tickets/{id} |
1件取得(org_id 不一致は404) |
GET /api/tickets/{id}/messages |
スレッド内メッセージ一覧(created_at昇順、delivery_status含む) |
POST /api/tickets/{id}/reply |
スタッフとして返信。is_staff=true で保存、status を pending_customer に、初回ならfirst_response_atを記録。send_email=true なら顧客へメール、結果をdelivery_statusに記録 |
POST /api/tickets/{id}/send-email |
スレッド全文を transcript.html/txt テンプレートで顧客へメール送信 |
POST /api/tickets/{id}/status |
status を直接更新。closedにするとresolved_atを記録、それ以外に戻すとクリア |
POST /api/tickets/{id}/viewed |
NEW last_staff_viewed_atを現在時刻に更新(未読バッジ用、issue #28) |
POST /api/tickets/{id}/messages/{message_id}/retry |
NEW delivery_status='failed'のメッセージの再送信を試みる(issue #27) |
GET /api/tickets/roles-config |
NEW(issue #35)自組織のhelpdesk_viewer_roles/helpdesk_manage_rolesを返す |
PUT /api/tickets/roles-config |
NEW 上記を更新。Admin/App Owner限定(§6-8) |
main.py の PUBLIC_PATHS 相当の除外リストに /api/public/tickets が明示登録されている)| メソッド/パス | 説明 |
|---|---|
PUBLIC GET /api/public/tickets/widget.js |
埋め込み用JSウィジェット本体を返す(?org_id= クエリで対象組織を指定) |
PUBLIC GET /api/public/tickets/turnstile-config |
NEW(issue #37){"site_key": ...}を返す。TURNSTILE_SITE_KEY未設定ならnull(ウィジェットはCAPTCHA自体を描画しない)。サイトキーは非秘匿情報(Cloudflareの設計上ウィジェットに埋め込む前提)なので公開して問題ない |
PUBLIC POST /api/public/tickets |
顧客がチケット新規作成。org_idはボディで指定。IP/org/宛先アドレスのレート制限(issue #26)・ハニーポット(hp_field)・**Turnstileトークン(turnstile_token、TURNSTILE_SECRET_KEY設定時のみ必須、issue #37)**を通過する必要がある。成功応答にpublic_tokenを含むがemail_verified_atはnull — 確認メールを送信するのみで、スタッフ通知はまだ発火しない(issue #38、§6-9) |
PUBLIC GET /api/public/tickets/verify/{token} |
NEW(issue #38)email_verified_atをセットし、この時点でスタッフ通知を発火。簡易HTML確認ページを返す(冪等 — 2回踏んでもエラーにならない) |
PUBLIC GET /api/public/tickets/by-token/{token} |
NEW(issue #25)public_tokenでチケット取得。128bitランダム値そのものが認証情報。email_verified_atがnullでも閲覧可(ウィジェットが「確認待ち」表示を出すため) |
PUBLIC GET /api/public/tickets/by-token/{token}/messages |
NEW 同上でスレッド取得。email_verified_atがnullの間は403(issue #38) |
PUBLIC POST /api/public/tickets/by-token/{token}/reply |
NEW 顧客として返信。レート制限+ハニーポット適用。未確認(email_verified_at IS NULL)なら403(issue #38) |
PUBLIC GET /api/public/tickets/{id}?email= |
DEPRECATED 旧: customer_emailの完全一致のみで本人確認(IDOR、§9参照)。移行期間中のみ後方互換で残す |
PUBLIC GET /api/public/tickets/{id}/messages?email= |
DEPRECATED 同上。こちらも未確認なら403(issue #38) |
PUBLIC POST /api/public/tickets/{id}/reply |
DEPRECATED 同上。新規のWidget/メールリンクは発行しないが動作は維持 |
widget.jsは新規発行分から常にby-token系のみを使う。旧?email=系は既存の埋め込みタグ・メールリンクとの互換のためだけに残している(削除時期は未定)。
helpdesk-widget.js)<script src=".../api/public/tickets/widget.js?org_id=11"></script> を任意のページに1行追加するだけで右下に丸いチャットボタンが出現する。localStorage(archi_active_ticket_token / _name)に保持 — サーバー側にセッションは無い。ポーリングはせず、ボタンを開くたびに都度フェッチする。2026-07-27改修: 以前保存していたarchi_active_ticket_id/_emailはpublic_token1本に置き換え(issue #25)。POST /api/public/tickets。フォームには画面上は見えないハニーポット欄(Websiteラベル、CSSでオフスクリーン配置)があり、ボットが自動入力するとhp_fieldとしてサーバーへ送られる(issue #26)。以後同じブラウザで開くと、email_verified_atが未確認の間は「確認メールを送りました」画面(「確認済み — 再確認」ボタン付き)を、確認済みならチケット状況画面(スレッド + 返信欄)を表示する(issue #38)。<script> タグ文字列をワンクリックでコピーできるようにしている。service/comms/support/ratelimit.py — プロセス内メモリのスライディングウィンドウ実装(Redis等の外部依存なし。1コンテナ1プロセス構成なのでこれで十分、複数プロセス化する場合はプロセスごとに別枠になる点に注意)。429 Too Many Requests。hp_field)に何か入っていた場合はレート制限チェックより先に判定し、DBに一切書き込まずに見た目だけ正常な200応答を返す(攻撃者に「弾かれた」ことを悟らせないため)。service/comms/support/turnstile.py::verify_token()がハニーポット判定の直後・レート制限チェックの前に呼ばれる(失敗したリクエストにレート制限枠を消費させないため)。TURNSTILE_SECRET_KEY未設定なら常にパスする no-op(このリポジトリの他の opt-in 機能と同じ既定=変更なしの原則)。フロントのVITE_TURNSTILE_SITE_KEY(ログイン画面向け、Supabase Auth自身が検証)とは別物 — こちらはウィジェットがGET /api/public/tickets/turnstile-configから取得したサイトキーを使い、このFastAPIバックエンド自身がCloudflareのsiteverify APIを叩いて検証する。詳細は§6-11。service/comms/support/templates/tickets/ 配下の reply.html/reply.txt/transcript.html/transcript.txt(Jinja2、autoescape はHTML拡張子のみ有効)。組織ごとのカスタマイズ機能は無い(Monitorのテンプレート機能とは異なり固定)。custom_ticket_messages.delivery_status(none|queued|sent|failed)に記録し、失敗時はdelivery_errorに理由も残す。UIのメッセージバブルに「送信失敗 ⚠ Retry」を表示し、POST /tickets/{id}/messages/{mid}/retryで再送できる。TICKET_TEMPLATE_DIRが実際には存在しないディレクトリ(service/comms/support/templates/tickets/)を指しており、実体は1階層上のservice/comms/templates/tickets/にあった。このモジュールが書かれて以来、_render()を呼ぶたびに常にTemplateNotFoundが発生していた — つまり返信メール・トランスクリプトメールは一度も実際に送信されたことがなかった。パスを修正済み。"Admin"ロールを持つメンバー全員へメール通知する(tickets.notify_staff())。System Monitorの既定通知先ロールと同じ考え方を踏襲 — 全メンバーへ通知すると煩雑になるための意図的な絞り込みで、閲覧・返信権限自体を変えるものではない(§9参照)。updated_at > last_staff_viewed_at(または未閲覧)のとき紫色の未読ドットを表示。POST /tickets/{id}/viewedをチケット選択のたびに呼び、既読化する。db.list_orgで組織の全チケットを無制限に取得していた(1組織1万件規模で確実に破綻する設計)。現在はupdated_at降順のカーソルページネーション(既定50件、最大200件)。updated_at(ISO8601、+00:00を含む)をそのまま使う設計上の注意点: クエリパラメータとして送る際は必ずURLエンコードすること(+を生のまま送るとサーバー側で空白に解釈される、application/x-www-form-urlencodedの仕様)。フロントはencodeURIComponentで対処済み。helpdesk_ticket_id として保存できる(手動リンク、自動同期なし)。sessionStorage.setItem("arch:helpdeskTicketId", ...) → Helpdeskタブへ画面遷移 → use-helpdesk.ts の fetchTickets() がそのIDを見つけて自動選択する、という一方向のディープリンク。ticket_createのみが記録され、返信・ステータス変更・公開経路の作成/返信は一切記録されていなかった。現在は ticket_reply / ticket_status_change / ticket_public_create / ticket_public_reply も記録する。user_idを持てないため、details文字列に[ip=... ua=...]としてクライアントIPとUser-Agentを埋め込む(_log_public_activity()、IPはX-Forwarded-For優先)。TicketChat.tsxはメッセージ本文が<tableまたは<divを含む場合、dangerouslySetInnerHTMLで無害化せずそのままHTML描画する(Monitor由来のテーブル付き本文を綺麗に見せるための分岐、§8-3)。未認証の公開経路(create_public_ticket/_handle_public_reply)から入るmessageにサーバー側の検証が無かったため、悪意ある送信者が<script>やイベントハンドラ属性を仕込んだ本文を送ると、スタッフがスレッドを開いた瞬間に任意コードが実行され得た(保存型XSS)。sanitize_rendered_html()(bleachベースの許可リスト方式、service/monitor/sanitize.py)を、公開経路の書き込み境界(create_public_ticket・_handle_public_replyの両方)で再利用。<script>・イベントハンドラ属性・許可外プロトコルを除去しつつ、<div>/<table>等の構造タグ自体は保持するので表示上の劣化は無い。view(閲覧のみ)/reply(閲覧+作成+返信)/manage(reply全て+close/reopen/トランスクリプト送信/再送)の3段階を追加。organizations.helpdesk_viewer_roles/helpdesk_manage_roles(§4)が両方とも空文字列の間は、全メンバーがmanage相当のフルアクセスを持つ — 今回の実装で既存組織の挙動は一切変わらない。Adminがこれらを設定して初めて特定ロールをview/reply止まりに制限できる、完全にopt-inな仕組み。"Admin"ロールは設定に関わらず常にmanage(Monitor設定やorg join_code再発行などと同じ既存の慣例)。GET/PUT /api/tickets/roles-config(§5-A)経由。書き込みはAdmin/App Owner限定。専用の設定画面UIはまだ無く、エンドポイントを直接叩く運用(P2でUI化予定)。customer_emailは、以前は入力値をそのまま信用していた(他人のアドレスを騙って送りつけられる)。現在は作成直後に確認メールを送信し、顧客がそのリンク(GET /api/public/tickets/verify/{token})を踏むまでemail_verified_atがnullのまま — この間、スレッド閲覧(messages)と返信は403。基本的なチケット状態(id/status/title)自体は未確認でも見えるので、ウィジェットは「確認待ち」画面を出せる(エラー表示にはしない)。public_token自体が128bitランダム値で、それを知っていることは既に「本人からのリンクを持っている」ことの証明になるため、毎回再確認させるのは摩擦を増やすだけで安全性向上に寄与しない、という判断(要件確定時の設計判断)。notify_staff()、§6-4)を発火するよう作成時から遅延させた — 未確認(=本人確認が取れていない)の送信についてスタッフに通知しても偽陽性が増えるだけなため。source='internal')チケットは認証済みスタッフ自身のメールなので、作成と同時にemail_verified_atをセット(マジックリンク不要)。移行前の既存行はcreated_atで一括バックフィル済み。POST /api/public/ticketsのボディに新フィールドturnstile_token(Cloudflareウィジェットの応答トークン)を追加し、turnstile.verify_token()がCloudflareのsiteverify APIへサーバーサイドで問い合わせて確認する。TURNSTILE_SECRET_KEYが未設定の環境ではverify_token()は常にTrueを返す no-op — このリポジトリの他のopt-in機能(Helpdesk RBAC §6-9等)と同じ「未設定なら今まで通り」の原則。helpdesk-widget.jsは起動時にGET /api/public/tickets/turnstile-configを叩き、site_keyがnullでなければCloudflareのapi.jsを動的ロードしてフォームにウィジェットを描画する。nullなら何もせず今まで通りのフォームのまま(サイトキー自体はCloudflareの設計上ウィジェットに埋め込む前提の非秘匿情報なので、公開エンドポイントで返して問題ない)。静的JSアセットであるwidget_js.py自体にはテンプレート変数を混ぜない設計を維持(コメント参照)。dev.arch-insight.comは許可ドメインに未登録)は本番.env専用。開発環境では、Cloudflareが公式に配布している「常にパスする」テスト用ダミーキー(サイトキー1x00000000000000000000AA / シークレット1x0000000000000000000000000000000AA、Cloudflare公式ドキュメント)を使う — ドメイン登録に関係なくどこでも動作するため、本番シークレットを開発環境に置く必要が無い。バックエンドの自動テストも同様に、このダミーの合格/失敗シークレットへmonkeypatchで一時的に切り替えて検証する(既存の関係無いテストが実際にCloudflareへネットワーク接続する事態を避けるため、環境変数としては既定で未設定のまま)。Helpdeskタブは2カラム構成(左: チケット一覧+検索+ステータスフィルタ、右: 選択中チケットのスレッド+返信欄)。上部にウィジェット埋め込みコードカードと新規作成カードが開閉式で入る。

左: ステータスフィルタ(All/Open/Pending Customer/Closed)付きの一覧、右: 選択中チケットのメッセージスレッドと返信欄。スタッフの発言は右寄せ紫、顧客の発言は左寄せで視覚的に区別。

「Create Issue / Ticket」ボタンで開閉。件名・本文・優先度(low/medium/high/urgent)を入力して内部チケットを作成する。

<script> タグ1行を外部サイトに追加するだけで表示される、フレームワーク非依存のフローティングウィジェット。ログイン不要で誰でも起票できる。

左: 一覧の未読チケットにタイトル左の紫ドット(last_staff_viewed_at未更新)。右: delivery_status='failed'のメッセージに表示される「Email delivery failed / Retry」。実際にRetryを押してSMTP再送が成功することを確認済み。
System Monitorのアラート発火処理(_fire_alert())がこのHelpdeskへ自動起票する、唯一の自動チケット作成経路。フロントエンドや外部Webhookは一切介さず、バックエンド内の直接関数呼び出し(backend/app/service/monitor/main.py → backend/app/service/monitor/notifier/zammad.py::create_ticket())で完結する。
2026-07-27修正(issue #34) 以前はalert_ticket.htmlという、構造的に「設定済み」になり得ないテンプレートも同時に要求しており、このゲートは常に「テンプレート未設定」側に倒れ「Yes」側には絶対に到達しなかった(§8-5に修正の経緯)。現在はalert_email.htmlのみが条件で、それが設定済みのorgでは正常に「Yes」へ進む。
create_ticket() は最大3回試行。1回目・2回目が例外を投げた場合は30秒待って再試行、3回とも失敗した場合は「チケット無しでメール送信」にフォールバックする(main.py のfor attempt in range(3): ... time.sleep(30))。send_alert_email() が例外を投げた場合はその時点で関数全体が return し、monitor_alerts への保存(save_alert)自体が行われない — チケットだけ作成されてDB上のアラート履歴には残らない状態になり得る。| custom_tickets列 | 値の由来 |
|---|---|
org_id |
settings.ORG_ID |
title |
"{test_prefix}[{tenant}][{severity.upper()}] {title}" — テストアラート時は先頭に [TEST] が付く |
status |
常に "open" |
priority |
2026-07-27修正 map_severity_to_priority(severity) の戻り値(critical→urgent、それ以外はhigh/medium/lowがそのまま該当)。以前はanalysis["severity"]を直接代入しており"critical"がPydanticのTicketPriority制約の外側に出ていたが、issue #30のCHECK制約と同時にこのマッパーで解消(§4) |
public_token |
NEW uuid.uuid4().hex で新規発行(issue #25) |
source |
NEW 常に "monitor" |
dedup_key |
NEW analysis.get("fingerprint")(§System Monitor仕様書のフィンガープリントをそのまま再利用 — issue #29、§8-6参照) |
customer_name |
固定文字列 "System Monitor" |
customer_email |
固定文字列 "monitor@arch-insight.com"(実在メールボックスではない — 公開チケットAPIの本人確認 ?email= と衝突しないダミー値) |
初回 custom_ticket_messages 本文 |
2026-07-27修正 Jinja2テンプレートを介さずnotifier/zammad.py::_ticket_body()がPythonの文字列組み立てで直接生成するプレーンHTML(severity色付きバッジ・tenant・summary・affected・action・ai_solution・context_htmlを埋め込み) — is_staff=false / sender_name="System Monitor"。context_htmlはサーバー生成のHTML(main.pyのメトリクステーブル等)なのでそのまま埋め込んで安全(顧客が送信する自由記述メッセージとは別経路 — issue #36参照)。 |
fingerprint(§System Monitor仕様書3-Aの重複排除と同じ値)を持つstatus != 'closed'のチケットが既に存在すれば、新規チケットを作らずそのチケットへメッセージを追記しoccurrence_countをインクリメントする。create_ticket()が実際に呼ばれるようになったため、このロジックも実運用で効果を発揮するようになった。Test Orgでの実配信検証でもoccurrence_countのインクリメント込みで動作を確認済み。alert_ticket.html は構造的に「設定済み」になり得なかった修正済み
修正前 main.py: if not (settings.template_configured("alert_email.html") and settings.template_configured("alert_ticket.html")): ... return state
template_configured(t) は内部で _template_key(t)("alert_ticket.html" → "TEMPLATE_ALERT_TICKET")を settings.TEMPLATES 辞書から引く。
しかし MonitorSettings.from_org() が組み立てる TEMPLATES 辞書のキーは TEMPLATE_ALERT_EMAIL と TEMPLATE_DAILY_REPORT の2つだけで、
TEMPLATE_ALERT_TICKET というキーはコードのどこにも存在しなかった(Templates画面の _TEMPLATE_TYPES にも、設定保存を許可する MONITOR_SETTING_KEYS にも無い)。
つまり template_configured("alert_ticket.html") は状況によらず常に False を返し、_fire_alert() のこのゲートは絶対に通過できなかった。
修正前に alert_email.html を保存済みのTest Org(org 11)で検証しても、ゲート全体は依然 False のままだった。
send_alert_email() の呼び出し箇所はコードベース全体で main.py のこの1箇所のみ(Send Test Emailのような別経路は存在しない)。
したがって修正前の実装では、System Monitorが検知したアラートはメールでもチケットでも一切配信されず、常に「テンプレート未設定」ログを出して monitor_alerts に記録されるだけで終わっていた。
推定される経緯: admin/settings.py のコメント「Support tickets are handled by the built-in Help Desk (no Zammad)」が示すとおり、
チケット発行は元々外部Zammad連携で専用テンプレートを持っていたと見られる。built-in Help Desk(custom_tickets直接INSERT)へ移行した際に
notifier/zammad.py::create_ticket() の実装は書き換えられたが、呼び出し元 main.py 側のテンプレート存在チェックが更新されずに取り残されたと考えられる。
git blame ではこの行は2026-07-05(v1.4.1)時点で既に存在しており、2026-07-26アーキテクチャレビューより前からの不具合だった。
修正内容(issue #34、2026-07-27): create_ticket()をalert_ticket.htmlテンプレートに依存しない形に変更(§8-3)、ゲートをalert_email.htmlのみの条件に変更。マージ前に、当時実際にalert_email.htmlとMonitor設定を持っていたのはTest Org(org 11、この検証のために自分で設定した組織)のみで、他組織への影響が無いことを確認済み。Test Orgで_fire_alert()を実際に呼び出し、実SMTPメール送信とチケット作成(#182)が初めて成功することをライブ検証した。
monitor_alerts.ticket_id(TEXT NULL)は custom_tickets.id への外部キーではなく、文字列化されたIDを緩く保持するだけ(§4のAgile Task同様、参照整合性は保証されない)。§8-5の修正(issue #34)以前は常にticket_id=nullだったが、現在はチケットが実際に作成された場合そのidが文字列として入る。
public_tokenによる認証(連番ID/メール一致ではない)。旧?email=系は移行期間として残置。delivery_status/delivery_errorで送信結果を記録しUIに表示、失敗時は再送ボタンから再試行できる。map_severity_to_priority()マッパーにより、Monitor由来のseverityは必ずTicketPriorityの制約内に収まる。custom_ticket_messages.ticket_idはON DELETE CASCADE付きの実FK、org_idも多層防御として保持。view/reply/manage)。両設定が既定の空文字列の間は既存組織の挙動を変えない。sanitize_rendered_html()で公開経路の書き込み時に無害化してから保存。TURNSTILE_SECRET_KEY設定時、POST /api/public/ticketsのturnstile_tokenをCloudflareのsiteverify APIでサーバーサイド検証(未設定なら常にパスするopt-in)。db.list_org/_get_ticket_for_orgで必ずorg_id一致を要求し、クロステナントアクセスは発生しない。