@aiquants/daily-report 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +13 -6
  2. package/dist/client.d.mts +438 -26
  3. package/dist/client.d.ts +438 -26
  4. package/dist/client.js +5 -4
  5. package/dist/client.js.map +1 -1
  6. package/dist/client.mjs +5 -4
  7. package/dist/client.mjs.map +1 -1
  8. package/dist/index.d.mts +115 -3
  9. package/dist/index.d.ts +115 -3
  10. package/dist/index.js +1 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +1 -1
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/server.d.mts +15 -0
  15. package/dist/server.d.ts +15 -0
  16. package/dist/server.js +9 -6
  17. package/dist/server.js.map +1 -1
  18. package/dist/server.mjs +9 -6
  19. package/dist/server.mjs.map +1 -1
  20. package/dist/{sse-schema-Df49KA7B.d.mts → sse-schema-BiUnufrI.d.mts} +12 -0
  21. package/dist/{sse-schema-RHckD7TS.d.ts → sse-schema-YZh7Pz7i.d.ts} +12 -0
  22. package/dist/styles/daily-report.standalone.css +1 -1
  23. package/package.json +3 -3
  24. package/src/client/components/daily-report-comment-item.tsx +6 -1
  25. package/src/client/components/daily-report-detail-list.tsx +65 -34
  26. package/src/client/components/daily-report-edit-form.tsx +6 -0
  27. package/src/client/components/daily-report-ids-stream-status.spec.tsx +93 -0
  28. package/src/client/components/daily-report-ids-stream-status.tsx +67 -0
  29. package/src/client/components/daily-report-list.tsx +88 -55
  30. package/src/client/components/daily-report-page.spec.tsx +119 -0
  31. package/src/client/components/daily-report-page.tsx +62 -69
  32. package/src/client/components/daily-report-resolved-content.tsx +36 -10
  33. package/src/client/components/report-views.spec.tsx +37 -2
  34. package/src/client/config-context.tsx +18 -0
  35. package/src/client/contexts/daily-report-action-context.spec.tsx +278 -0
  36. package/src/client/contexts/daily-report-action-context.tsx +203 -65
  37. package/src/client/contexts/daily-report-error-context.spec.tsx +66 -0
  38. package/src/client/contexts/daily-report-error-context.tsx +98 -0
  39. package/src/client/hooks/use-animated-number.spec.ts +103 -0
  40. package/src/client/hooks/use-animated-number.ts +69 -0
  41. package/src/client/hooks/use-daily-report.spec.ts +50 -2
  42. package/src/client/hooks/use-daily-report.ts +30 -1
  43. package/src/client/route-helpers.spec.ts +100 -0
  44. package/src/client/route-helpers.ts +20 -41
  45. package/src/client/streaming/daily-report-ids-stream-client.spec.ts +704 -0
  46. package/src/client/streaming/daily-report-ids-stream-client.ts +677 -0
  47. package/src/client/streaming/daily-report-ids-stream-session.spec.ts +360 -0
  48. package/src/client/streaming/daily-report-ids-stream-session.ts +280 -0
  49. package/src/client.ts +5 -0
  50. package/src/index.ts +1 -0
  51. package/src/server/handlers.action.spec.ts +44 -0
  52. package/src/server/handlers.ids-stream.spec.ts +414 -0
  53. package/src/server/handlers.ts +324 -8
  54. package/src/server/service.spec.ts +83 -7
  55. package/src/server/service.ts +100 -3
  56. package/src/server/test-helpers/handlers-config.ts +48 -1
  57. package/src/server.spec.ts +49 -0
  58. package/src/server.ts +7 -0
  59. package/src/shared/business-date.spec.ts +22 -1
  60. package/src/shared/business-date.ts +20 -0
  61. package/src/shared/comment-adapter.spec.ts +38 -0
  62. package/src/shared/comment-adapter.ts +9 -7
  63. package/src/shared/ids-stream.spec.ts +65 -0
  64. package/src/shared/ids-stream.ts +139 -0
  65. package/src/shared/sse-schema.ts +5 -0
  66. package/src/shared/text-utils.spec.ts +42 -0
  67. package/src/shared/text-utils.ts +8 -0
@@ -7,8 +7,10 @@
7
7
  * - sse.loader: Redis Streams ベースのリアルタイム更新 SSE エンドポイント
8
8
  */
9
9
  import { normalizeBusinessDateKey } from "../shared/business-date"
10
+ import { type DailyReportIdsStreamChunkLine, type DailyReportIdsStreamCursor, findIdsStreamResumeIndex } from "../shared/ids-stream"
10
11
  import { createLogger, type DailyReportLogger, LogLevel } from "../shared/logger"
11
12
  import { dailyReportSseMessageSchema } from "../shared/sse-schema"
13
+ import type { DailyReportItem } from "../shared/types"
12
14
  import type { DailyReportAttachmentFailure, DailyReportAuthenticate, DailyReportEncodeUserId, DailyReportIdCodec, DailyReportReadAttachment, DailyReportRedisProvider } from "./ports"
13
15
  import { jsonResponseWithETag } from "./response"
14
16
  import type { DailyReportService } from "./service"
@@ -24,6 +26,220 @@ const jsonData = (payload: unknown, init?: { status?: number }): Response =>
24
26
  headers: { "Content-Type": "application/json" },
25
27
  })
26
28
 
29
+ // ---------------- ids NDJSON ストリームの定数とヘルパー (純粋・モジュールスコープ) ----------------
30
+
31
+ /**
32
+ * First-chunk size, tuned for time-to-first-render.
33
+ * 初回チャンクのアイテム数。最初の描画までの時間を優先して小さく保つ。
34
+ *
35
+ * 一覧の可視行 + オーバースキャンを一度に満たせる件数であればよい。
36
+ */
37
+ const IDS_STREAM_FIRST_CHUNK_SIZE = 200
38
+
39
+ /**
40
+ * 2 チャンク目以降のアイテム数。1 アイテムが数十バイト (id/日付/種別のみ) と軽いため
41
+ * 大きめに運ぶ。実データは 20 万件超に達するため、小さすぎると行数 (= pull 往復と
42
+ * クライアント側 publish 回数) が肥大する (4000 件 ≈ 240 KB/行、20 万件で約 50 行)。
43
+ */
44
+ const IDS_STREAM_NEXT_CHUNK_SIZE = 4000
45
+
46
+ /**
47
+ * Sanitized message for mid-stream failures (no internal details).
48
+ * ストリーム送出中の失敗をクライアントへ伝える定型文 (内部情報は載せない)。
49
+ */
50
+ const IDS_STREAM_ERROR_CLIENT_MESSAGE = "stream interrupted by server error"
51
+
52
+ /** 再開カーソルの営業日として受け付ける唯一の形式 (正規化済み YYYY-MM-DD)。 */
53
+ const IDS_STREAM_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
54
+
55
+ /**
56
+ * Heartbeat interval while the next chunk is pending (ms).
57
+ * 次チャンク待ちの間に空行ハートビートを送る間隔 (ms)。
58
+ *
59
+ * 全量クエリ (20 万件超) の確定待ちで無音が続くと、クライアントの stall ウォッチドッグ
60
+ * (既定 30s) が生きた接続を half-open と誤認する。それより十分短い間隔で空行を送り、
61
+ * read を解決させて誤検知を防ぐ。空行はクライアント側で読み飛ばされる (`trim() === ""`)。
62
+ */
63
+ const IDS_STREAM_HEARTBEAT_MS = 15_000
64
+
65
+ /** next() 待ちがハートビート間隔に負けたことを表す番兵。 */
66
+ const IDS_STREAM_HEARTBEAT = Symbol("ids-stream-heartbeat")
67
+
68
+ /**
69
+ * Parses and validates resume-cursor query params (fail-closed).
70
+ * 再開カーソルのクエリパラメータを検証付きで解釈する処理 (不正は fail-closed)。
71
+ *
72
+ * - `cursor_report_hub_id` 単独 = 「営業日なし領域」のカーソル (businessDate: null)。
73
+ * - `cursor_business_date` は `cursor_report_hub_id` 無しでは意味を持たないため 400。
74
+ *
75
+ * @param url Request URL. リクエスト URL。
76
+ * @returns Cursor (null = 先頭から) か、検証失敗の印。
77
+ */
78
+ const parseIdsStreamCursor = (url: URL): { cursor: DailyReportIdsStreamCursor | null } | { invalid: true } => {
79
+ const rawId = url.searchParams.get("cursor_report_hub_id")
80
+ const rawDate = url.searchParams.get("cursor_business_date")
81
+
82
+ if (rawId === null) {
83
+ // 営業日だけのカーソルは並び順上の位置を決められない
84
+ return rawDate === null ? { cursor: null } : { invalid: true }
85
+ }
86
+
87
+ const reportHubId = Number.parseInt(rawId, 10)
88
+ if (!Number.isSafeInteger(reportHubId) || String(reportHubId) !== rawId || reportHubId <= 0) {
89
+ return { invalid: true }
90
+ }
91
+
92
+ if (rawDate === null) {
93
+ return { cursor: { businessDate: null, reportHubId } }
94
+ }
95
+ if (!IDS_STREAM_DATE_PATTERN.test(rawDate)) {
96
+ return { invalid: true }
97
+ }
98
+ return { cursor: { businessDate: rawDate, reportHubId } }
99
+ }
100
+
101
+ /**
102
+ * Emits a finalized id array as chunked NDJSON lines from a resume position.
103
+ * 確定済み ID 配列を再開位置からチャンク行として送出するジェネレーター。
104
+ *
105
+ * `total` は最初に yield する行へ載せる (= 接続内で全量が確定した最初の行。
106
+ * キャッシュヒットの新規接続と再開接続では先頭行、高速先頭ページ経路では 2 行目)。
107
+ *
108
+ * @param items Stream-ordered full array. ストリーム順の全量配列。
109
+ * @param resumeKey Resume position (null = 先頭から)。
110
+ * @param firstChunkSize このジェネレーターの最初のチャンクサイズ。
111
+ */
112
+ async function* streamLinesFromFullArray(items: DailyReportItem[], resumeKey: DailyReportIdsStreamCursor | null, firstChunkSize = IDS_STREAM_FIRST_CHUNK_SIZE): AsyncGenerator<DailyReportIdsStreamChunkLine> {
113
+ let position = resumeKey ? findIdsStreamResumeIndex(items, resumeKey) : 0
114
+ let isFirstYield = true
115
+ while (true) {
116
+ const chunkItems = items.slice(position, position + (isFirstYield ? firstChunkSize : IDS_STREAM_NEXT_CHUNK_SIZE))
117
+ position += chunkItems.length
118
+ const isLast = position >= items.length
119
+ const lastItem = chunkItems[chunkItems.length - 1]
120
+ yield {
121
+ items: chunkItems,
122
+ nextCursor: isLast || !lastItem ? null : { businessDate: lastItem.businessDate, reportHubId: lastItem.reportHubId },
123
+ isLast,
124
+ ...(isFirstYield ? { total: items.length } : {}),
125
+ }
126
+ isFirstYield = false
127
+ if (isLast) return
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Wraps a line iterator into a pull-based NDJSON streaming Response.
133
+ * 行イテレーターを pull 駆動の NDJSON ストリーミング Response へ包む処理。
134
+ *
135
+ * - 切断検知時はイテレーターを畳んで (`return()`) DB 待ちを解放し、close する
136
+ * (素の return は desiredSize > 0 のまま pull が再帰する)。
137
+ * - イテレーターの throw は内部情報を含まない定型エラー行 + 正常クローズへ倒す
138
+ * (クライアントは「isLast 無し終端」をカーソル再開のシグナルとして扱う)。
139
+ */
140
+ const buildIdsStreamResponse = (cookie: string | null, lines: AsyncGenerator<DailyReportIdsStreamChunkLine>, request: Request, logger: DailyReportLogger): Response => {
141
+ const encoder = new TextEncoder()
142
+ // cancel() の lines.return() は「pull が DB 待ちの next() を保留中」だと先取りできない。
143
+ // 待ちが明けた後の enqueue が閉じたストリームへ落ちて catch に入るため、切断は
144
+ // フラグで決定的に識別し、通常イベントとして静かに畳む (ERROR ログにしない —
145
+ // 全量クエリ中の離脱は日常イベントで、本物の送出失敗が雑音に埋もれる)
146
+ let cancelled = false
147
+ // next() の解決はハートビートを跨いで保持する。ハートビートが先着したら空行を出し、
148
+ // 同じ next() を次の pull で await し直す (毎回 next() を呼び直すと二重取得になる)。
149
+ let pendingNext: Promise<IteratorResult<DailyReportIdsStreamChunkLine>> | null = null
150
+ const stream = new ReadableStream<Uint8Array>({
151
+ async pull(controller) {
152
+ if (cancelled || request.signal.aborted) {
153
+ await lines.return(undefined).catch(() => {})
154
+ try {
155
+ controller.close()
156
+ } catch {
157
+ // cancel 済みコントローラーの close は失敗してよい
158
+ }
159
+ return
160
+ }
161
+ let heartbeatTimer: ReturnType<typeof setTimeout> | null = null
162
+ try {
163
+ if (pendingNext === null) {
164
+ pendingNext = lines.next()
165
+ }
166
+ // 次チャンクの確定待ちが長引く間、接続の生存を示す空行を送る
167
+ const heartbeatPromise = new Promise<typeof IDS_STREAM_HEARTBEAT>((resolve) => {
168
+ heartbeatTimer = setTimeout(() => resolve(IDS_STREAM_HEARTBEAT), IDS_STREAM_HEARTBEAT_MS)
169
+ })
170
+ const raced = await Promise.race([pendingNext, heartbeatPromise])
171
+ if (heartbeatTimer !== null) {
172
+ clearTimeout(heartbeatTimer)
173
+ heartbeatTimer = null
174
+ }
175
+ if (raced === IDS_STREAM_HEARTBEAT) {
176
+ // next() はまだ保留中。空行だけ送って pending は次の pull へ持ち越す
177
+ if (cancelled || request.signal.aborted) {
178
+ await lines.return(undefined).catch(() => {})
179
+ try {
180
+ controller.close()
181
+ } catch {
182
+ // cancel 済みコントローラーの close は失敗してよい
183
+ }
184
+ return
185
+ }
186
+ controller.enqueue(encoder.encode("\n"))
187
+ return
188
+ }
189
+ pendingNext = null
190
+ const { value, done } = raced
191
+ if (done) {
192
+ controller.close()
193
+ return
194
+ }
195
+ // 保留中の next() の間に切断された場合はここで検知して静かに終える
196
+ if (cancelled || request.signal.aborted) {
197
+ await lines.return(undefined).catch(() => {})
198
+ try {
199
+ controller.close()
200
+ } catch {
201
+ // cancel 済みコントローラーの close は失敗してよい
202
+ }
203
+ return
204
+ }
205
+ controller.enqueue(encoder.encode(`${JSON.stringify(value)}\n`))
206
+ } catch (error) {
207
+ if (heartbeatTimer !== null) {
208
+ clearTimeout(heartbeatTimer)
209
+ }
210
+ // next() が throw した = このイテレーターは畳む。保留参照を残さない
211
+ pendingNext = null
212
+ if (cancelled || request.signal.aborted) {
213
+ // 切断起因の enqueue/close 失敗はエラーではない
214
+ return
215
+ }
216
+ logger.error(`ids-stream emission failed: ${error instanceof Error ? error.message : String(error)}`)
217
+ try {
218
+ controller.enqueue(encoder.encode(`${JSON.stringify({ error: IDS_STREAM_ERROR_CLIENT_MESSAGE })}\n`))
219
+ controller.close()
220
+ } catch {
221
+ // 既に切断済みなら何もできない
222
+ }
223
+ }
224
+ },
225
+ cancel() {
226
+ cancelled = true
227
+ void lines.return(undefined).catch(() => {})
228
+ },
229
+ })
230
+
231
+ const headers = new Headers({
232
+ "Content-Type": "application/x-ndjson; charset=utf-8",
233
+ // no-transform: compression ミドルウェア/中間プロキシによるバッファリングを禁止する (SSE と同じ理由)
234
+ "Cache-Control": "private, no-cache, no-transform",
235
+ "X-Content-Type-Options": "nosniff",
236
+ })
237
+ if (cookie) {
238
+ headers.append("Set-Cookie", cookie)
239
+ }
240
+ return new Response(stream, { status: 200, headers })
241
+ }
242
+
27
243
  // ---------------- 添付配信の定数とヘルパー (純粋・モジュールスコープ) ----------------
28
244
 
29
245
  /** 添付 1 件あたりの既定上限。サービス側のワイヤ上限 (既定 64 MiB) より必ず低く保つ。 */
@@ -302,6 +518,13 @@ export type DailyReportHandlersConfig = {
302
518
  streamKey: string
303
519
  /** 未ログイン時のリダイレクト先 (index.loader 用、既定 "/auth/login")。 */
304
520
  loginRedirectPath?: string
521
+ /**
522
+ * `intent=clearCache` (全サーバーキャッシュの一括破棄) を許可するか。既定 false。
523
+ * 開発用アフォーダンスであり、有効化すると認証済みユーザーなら誰でも全ワーカーの
524
+ * キャッシュを無制限に破棄できるため、本番では必ず無効のままにする
525
+ * (消費アプリは開発環境でのみ true を注入する)。
526
+ */
527
+ enableDevCacheClear?: boolean
305
528
  /** ロガー (既定は console ベース)。 */
306
529
  logger?: DailyReportLogger
307
530
  }
@@ -370,13 +593,88 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
370
593
  return jsonResponseWithETag(request, cookie, { businessDate: normalizedBusinessDate, reports }, 200)
371
594
  },
372
595
  /**
373
- * Retrieves a list of all daily report IDs.
374
- * 全ての日報 ID の一覧を取得する。
596
+ * Streams the full daily report id list as NDJSON chunks.
597
+ * 日報 ID 一覧全件を NDJSON チャンクとして逐次配信する。
598
+ *
599
+ * 全量クエリ (`SqlResultCache` の TTL / Redis epoch / in-flight コアレッシングを
600
+ * 素通しで維持) は常に即時に開始する。全量確定を待ってから送出するのは再開接続のみで、
601
+ * その場合の DB エラーはストリーム開始前に throw されて apiLoader の catch が
602
+ * 500 JSON を返す。新規接続はキャッシュ無しの TOP 200 高速先頭ページと競争させ、
603
+ * キャッシュミス時は全量確定前に最初のチャンクを送出する (最初の描画までの時間が
604
+ * 全体データ量に依存しない)。キャッシュヒット時は全量が即勝ちし、従来どおり
605
+ * 確定済み配列のメモリスライス送出になる。
375
606
  */
376
- ids: async (url, cookie, request, user) => {
607
+ "ids-stream": async (url, cookie, request, user) => {
608
+ const parsed = parseIdsStreamCursor(url)
609
+ if ("invalid" in parsed) {
610
+ return jsonResponseWithETag(request, cookie, { error: { message: "Invalid cursor" } }, 400)
611
+ }
377
612
  const forceRefresh = url.searchParams.get("forceRefresh") === "true"
378
- const dailyReportIds = await service.getDailyReportIdsByExternalId(user.id, { forceRefresh })
379
- return jsonResponseWithETag(request, cookie, { ids: dailyReportIds }, 200)
613
+
614
+ // 全量 (SqlResultCache / epoch / コアレッシング維持) は常に即時に走らせ始める
615
+ const fullArrayPromise = service.getDailyReportIdsByExternalId(user.id, { forceRefresh })
616
+ // 拒否はイテレーターの await 側で扱う (ここで未処理拒否警告を出さない)
617
+ fullArrayPromise.catch(() => {})
618
+
619
+ if (parsed.cursor) {
620
+ // 再開接続: クライアントは高速先頭を受信済みなので全量確定を待つ (エラーは 500)。
621
+ // 全量クエリはアプリ側の requestTimeout (既定 60s) で境界づけられ、確定後は
622
+ // スライスだけなので、接続確立後の無音はハートビート対象にならない
623
+ return buildIdsStreamResponse(cookie, streamLinesFromFullArray(await fullArrayPromise, parsed.cursor), request, apiLogger)
624
+ }
625
+
626
+ /**
627
+ * 新規接続: 「高速先頭ページ (TOP n・キャッシュ無し)」と「全量 (キャッシュ経由)」を
628
+ * 競争させ、先に確定した方で最初のチャンクを出す。キャッシュヒット時は全量が即勝ち
629
+ * (従来どおり先頭行に total)。キャッシュミス時は先頭ページが先に届き、最初の描画は
630
+ * 全量クエリの確定を待たない。total は全量確定後の最初の行に載る。
631
+ */
632
+ // キャッシュヒット時もこの TOP 200 クエリは走って結果が捨てられる (二重クエリ)。
633
+ // インデックス順の部分走査で ms 級と実測見積もりされており、ヒット判定のための
634
+ // キャッシュ同期 peek API を足す複雑さに見合わないため意図的に許容する
635
+ const firstPagePromise = service.getDailyReportIdsFirstPageByExternalId(user.id, IDS_STREAM_FIRST_CHUNK_SIZE)
636
+ firstPagePromise.catch(() => {})
637
+
638
+ const lineIterator = (async function* (): AsyncGenerator<DailyReportIdsStreamChunkLine> {
639
+ const winner = await Promise.race([
640
+ fullArrayPromise.then(
641
+ (items) => ({ kind: "full" as const, items }),
642
+ (error) => ({ kind: "full-error" as const, error }),
643
+ ),
644
+ firstPagePromise.then(
645
+ (items) => ({ kind: "page" as const, items }),
646
+ () => ({ kind: "page-error" as const }),
647
+ ),
648
+ ])
649
+
650
+ let resumeKey: DailyReportIdsStreamCursor | null = null
651
+ let fullItems: DailyReportItem[] | null = null
652
+
653
+ if (winner.kind === "full") {
654
+ fullItems = winner.items
655
+ } else if (winner.kind === "page" || winner.kind === "full-error") {
656
+ // 全量が先に死んでいても先頭ページが生きていれば、まずそれを届ける
657
+ const pageItems = winner.kind === "page" ? winner.items : await firstPagePromise
658
+ if (pageItems.length < IDS_STREAM_FIRST_CHUNK_SIZE) {
659
+ // 先頭ページが上限未満 = データセット全量。全量クエリを待たずに完結する
660
+ yield { items: pageItems, nextCursor: null, isLast: true, total: pageItems.length }
661
+ return
662
+ }
663
+ const lastItem = pageItems[pageItems.length - 1]
664
+ resumeKey = { businessDate: lastItem.businessDate, reportHubId: lastItem.reportHubId }
665
+ // total は未知 (全量確定後の行に載せる)
666
+ yield { items: pageItems, nextCursor: resumeKey, isLast: false }
667
+ }
668
+ // page-error は全量へフォールバック。full-error は上の分岐でページを出してから、ここで throw する
669
+ if (fullItems === null) {
670
+ fullItems = await fullArrayPromise
671
+ }
672
+ // 高速先頭ページ経路 (resumeKey あり) はクライアントが既に 200 件を描画済み
673
+ // なので、続きは大きいチャンクで運ぶ。キャッシュヒット経路は従来どおり 200 件先頭
674
+ yield* streamLinesFromFullArray(fullItems, resumeKey, resumeKey ? IDS_STREAM_NEXT_CHUNK_SIZE : IDS_STREAM_FIRST_CHUNK_SIZE)
675
+ })()
676
+
677
+ return buildIdsStreamResponse(cookie, lineIterator, request, apiLogger)
380
678
  },
381
679
  /**
382
680
  * Retrieves details for a specific daily report.
@@ -460,6 +758,11 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
460
758
  const clientTempId = formData.get("clientTempId") as string | null
461
759
 
462
760
  if (intent === "clearCache") {
761
+ // 開発専用アフォーダンス。既定は封鎖 (認証済みなら誰でも全ワーカーの
762
+ // キャッシュを無制限破棄できてしまうため、注入で明示的に開けた環境のみ許可)
763
+ if (!config.enableDevCacheClear) {
764
+ return jsonData({ error: "Invalid intent" }, { status: 400 })
765
+ }
463
766
  await service.clearCache()
464
767
  return jsonData({ status: "OK", intent: "clearCache" })
465
768
  }
@@ -567,9 +870,22 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
567
870
  if (!content) {
568
871
  return jsonData({ error: "Content required" }, { status: 400 })
569
872
  }
570
- const newComment = await service.addComment(userId, reportHubId, content, businessDate, clientTempId)
571
- const safeComment = { ...newComment, userId: newComment.userId }
572
- return jsonData({ status: "OK", intent: "addComment", newComment: safeComment, reportHubId: String(reportHubId), clientTempId })
873
+ try {
874
+ const newComment = await service.addComment(userId, reportHubId, content, businessDate, clientTempId)
875
+ const safeComment = { ...newComment, userId: newComment.userId }
876
+ return jsonData({ status: "OK", intent: "addComment", newComment: safeComment, reportHubId: String(reportHubId), clientTempId })
877
+ } catch (e: unknown) {
878
+ if (e instanceof Error) {
879
+ // 削除直後の日報への投稿レース等。500 ではなく意味のある状態コードへ写像する
880
+ if (e.message === "Not Found") {
881
+ return jsonData({ error: "Report not found" }, { status: 404 })
882
+ }
883
+ if (e.message.startsWith("Comments are restricted")) {
884
+ return jsonData({ error: "Comments are restricted for this report" }, { status: 403 })
885
+ }
886
+ }
887
+ throw e
888
+ }
573
889
  }
574
890
  case "deleteComment": {
575
891
  const commentId = Number(formData.get("commentId"))
@@ -55,10 +55,14 @@ type RecordedUpdate = {
55
55
  * @param rows Rows every query resolves to. すべてのクエリが返す行。
56
56
  * @returns The db mock and the per-query recordings. db モックとクエリごとの記録。
57
57
  */
58
- const makeDb = (rows: unknown[]): { db: DailyReportDb; queries: RecordedQuery[]; updates: RecordedUpdate[] } => {
58
+ const makeDb = (rows: unknown[], options: { selectQueue?: unknown[][] } = {}): { db: DailyReportDb; queries: RecordedQuery[]; updates: RecordedUpdate[] } => {
59
59
  const queries: RecordedQuery[] = []
60
60
  const updates: RecordedUpdate[] = []
61
+ // selectQueue があれば select 呼び出しごとに 1 要素ずつ消費する (枯れたら rows へフォールバック)。
62
+ // 複数 select が異なる結果を返す必要のあるフロー (draft 判定など) の検証に使う
63
+ const selectQueue = options.selectQueue ? [...options.selectQueue] : null
61
64
  const select = () => {
65
+ const result = selectQueue && selectQueue.length > 0 ? (selectQueue.shift() as unknown[]) : rows
62
66
  const recorded: RecordedQuery = { whereSql: null, whereParams: [], leftJoins: 0, joinParams: [] }
63
67
  queries.push(recorded)
64
68
  const chain: Record<string, unknown> = {
@@ -76,11 +80,11 @@ const makeDb = (rows: unknown[]): { db: DailyReportDb; queries: RecordedQuery[];
76
80
  return chain
77
81
  },
78
82
  top: () => chain,
79
- orderBy: () => Promise.resolve(rows),
83
+ orderBy: () => Promise.resolve(result),
80
84
  // `.where()` / `.orderBy()` のどちらで終わっても await できるようにする。
81
85
  // thenable であること自体が目的なので、この規則はここでは意図的に外す
82
86
  // biome-ignore lint/suspicious/noThenProperty: 意図的な thenable (クエリビルダの終端を模擬する)
83
- then: (resolve: (value: unknown) => unknown) => resolve(rows),
87
+ then: (resolve: (value: unknown) => unknown) => resolve(result),
84
88
  }
85
89
  return chain
86
90
  }
@@ -99,7 +103,17 @@ const makeDb = (rows: unknown[]): { db: DailyReportDb; queries: RecordedQuery[];
99
103
  }
100
104
  return chain
101
105
  }
102
- return { db: { select, update } as unknown as DailyReportDb, queries, updates }
106
+ // INSERT: values をそのまま id 付きでエコーする (addComment の inserted 行を模擬)
107
+ const insert = () => {
108
+ const chain: Record<string, unknown> = {
109
+ output: () => chain,
110
+ values: (values: Record<string, unknown>) => Promise.resolve([{ id: 501, ...values }]),
111
+ }
112
+ return chain
113
+ }
114
+ // DELETE: 記録のみ (deleteComment 用)
115
+ const del = () => ({ where: () => Promise.resolve({ rowsAffected: [1] }) })
116
+ return { db: { select, update, insert, delete: del } as unknown as DailyReportDb, queries, updates }
103
117
  }
104
118
 
105
119
  const baseHub = (over: Partial<DailyReportHubRow>): DailyReportHubRow => ({
@@ -120,10 +134,20 @@ const baseHub = (over: Partial<DailyReportHubRow>): DailyReportHubRow => ({
120
134
  ...over,
121
135
  })
122
136
 
123
- const makeService = (rows: unknown[], withAttachmentCodec = false) => {
137
+ const makeService = (rows: unknown[], withAttachmentCodec = false, options: { selectQueue?: unknown[][] } = {}) => {
124
138
  const epochs = createEpochStore(undefined)
125
139
  const cache = new SqlResultCache({ defaultTtlMs: 60_000 }, epochs)
126
- const { db, queries, updates } = makeDb(rows)
140
+ const { db, queries, updates } = makeDb(rows, { selectQueue: options.selectQueue })
141
+ // SSE publish (redis xAdd) の捕捉。宛先フィルタ (recipientRawUserId) の検証に使う
142
+ const sseMessages: Record<string, unknown>[] = []
143
+ const redis = {
144
+ getClient: async () => ({
145
+ xAdd: async (_key: string, _id: string, fields: { data: string }) => {
146
+ sseMessages.push(JSON.parse(fields.data) as Record<string, unknown>)
147
+ return "1-1"
148
+ },
149
+ }),
150
+ }
127
151
  const service = createDailyReportService({
128
152
  db,
129
153
  tables,
@@ -132,12 +156,13 @@ const makeService = (rows: unknown[], withAttachmentCodec = false) => {
132
156
  encodeUserId: (id) => `ENC${id}`, // テスト用の識別可能な難読化
133
157
  cache,
134
158
  epochs,
159
+ redis: redis as unknown as Parameters<typeof createDailyReportService>[0]["redis"],
135
160
  draftLabelName: "draft", // 必須注入 (ドラフト可視性フィルタ用のラベル名。テストは中立値で十分)
136
161
  // 既定では未注入のまま。既存テストが縮退動作 (添付は常に空) で通ることも同時に確認する。
137
162
  // ユーザー ID 用 (`ENC`) とは別の接頭辞にして ID 空間の分離を表現する。
138
163
  ...(withAttachmentCodec ? { attachmentIdCodec: { encode: (id: number) => `ATT${id}`, decode: (t: string) => (t.startsWith("ATT") ? Number(t.slice(3)) : null) } } : {}),
139
164
  })
140
- return Object.assign(service, { __queries: queries, __updates: updates })
165
+ return Object.assign(service, { __queries: queries, __updates: updates, __sse: sseMessages })
141
166
  }
142
167
 
143
168
  describe("mapHubRecord audit-actor masking (security)", () => {
@@ -361,3 +386,54 @@ describe("実体存在の記録 (三値の書き込み側)", () => {
361
386
  expect(updates).toHaveLength(0)
362
387
  })
363
388
  })
389
+
390
+ describe("コメント操作の整合性チェック", () => {
391
+ it("addComment は存在しない日報に対して Not Found を投げる (孤児コメントを作らない)", async () => {
392
+ // hub 取得が空 = 存在しない日報。以前は hub 未取得でもフォールスルーして INSERT していた
393
+ const svc = makeService([])
394
+ await expect(svc.addComment(8, 999, "本文", "2099-01-05", "tmp")).rejects.toThrow("Not Found")
395
+ })
396
+
397
+ it("addComment は論理削除済みの日報を拒否する", async () => {
398
+ const svc = makeService([{ sourceType: "Internal", deletedAt: "2099-01-06 00:00:00", userId: 8 }])
399
+ await expect(svc.addComment(8, 1, "本文", "2099-01-05", "tmp")).rejects.toThrow("Not Found")
400
+ })
401
+
402
+ it("deleteComment は reportHubId がコメントの実所属と一致しないと Not Found を投げる", async () => {
403
+ // コメントは hubId=100 に属するのに、呼び出しは reportHubId=999 を渡している。
404
+ // 不一致のまま進むと無関係な日報のキャッシュ無効化と SSE 配信を起こすため拒否する
405
+ const svc = makeService([{ id: 5, hubId: 100, userId: 8, body: "x", createdAt: "2099-01-05 00:00:00", createdBy: "8", updatedAt: "2099-01-05 00:00:00", updatedBy: "8" }])
406
+ await expect(svc.deleteComment(8, 999, 5, "2099-01-05", "tmp")).rejects.toThrow("Not Found")
407
+ })
408
+
409
+ it("addComment: 公開済み日報のコメントは全員配信 (recipientRawUserId 無し)", async () => {
410
+ const svc = makeService([], false, {
411
+ selectQueue: [
412
+ [{ sourceType: "Internal", deletedAt: null, userId: 8 }], // hub 取得
413
+ [{ id: 10 }], // getDraftLabelJoinIds (下書きラベル定義)
414
+ [], // 下書き関連 (該当なし = 公開済み)
415
+ [{ displayName: "山田" }], // コメント投稿者名
416
+ ],
417
+ })
418
+ await svc.addComment(8, 1, "本文", "2099-01-05", "tmp")
419
+ const sse = (svc as unknown as { __sse: Record<string, unknown>[] }).__sse
420
+ const add = sse.find((m) => m.type === "comment-add")
421
+ expect(add).toBeDefined()
422
+ expect(add?.recipientRawUserId).toBeUndefined()
423
+ })
424
+
425
+ it("addComment: 下書き日報のコメントは所有者だけに配信 (本文の全員配信を防ぐ)", async () => {
426
+ const svc = makeService([], false, {
427
+ selectQueue: [
428
+ [{ sourceType: "Internal", deletedAt: null, userId: 8 }], // hub (所有者 8)
429
+ [{ id: 10 }], // getDraftLabelJoinIds
430
+ [{ hubId: 1 }], // 下書き関連あり = 下書き
431
+ [{ displayName: "山田" }], // 投稿者名
432
+ ],
433
+ })
434
+ await svc.addComment(8, 1, "秘密の下書きコメント", "2099-01-05", "tmp")
435
+ const sse = (svc as unknown as { __sse: Record<string, unknown>[] }).__sse
436
+ const add = sse.find((m) => m.type === "comment-add")
437
+ expect(add?.recipientRawUserId).toBe(8)
438
+ })
439
+ })