@aiquants/daily-report 0.8.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 (56) hide show
  1. package/dist/client.d.mts +76 -6
  2. package/dist/client.d.ts +76 -6
  3. package/dist/client.js +5 -5
  4. package/dist/client.js.map +1 -1
  5. package/dist/client.mjs +5 -5
  6. package/dist/client.mjs.map +1 -1
  7. package/dist/index.d.mts +40 -2
  8. package/dist/index.d.ts +40 -2
  9. package/dist/index.js +1 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.mjs +1 -1
  12. package/dist/index.mjs.map +1 -1
  13. package/dist/server.d.mts +13 -0
  14. package/dist/server.d.ts +13 -0
  15. package/dist/server.js +8 -7
  16. package/dist/server.js.map +1 -1
  17. package/dist/server.mjs +8 -7
  18. package/dist/server.mjs.map +1 -1
  19. package/dist/{sse-schema-Df49KA7B.d.mts → sse-schema-BiUnufrI.d.mts} +12 -0
  20. package/dist/{sse-schema-RHckD7TS.d.ts → sse-schema-YZh7Pz7i.d.ts} +12 -0
  21. package/dist/styles/daily-report.standalone.css +1 -1
  22. package/package.json +1 -1
  23. package/src/client/components/daily-report-comment-item.tsx +6 -1
  24. package/src/client/components/daily-report-detail-list.tsx +65 -34
  25. package/src/client/components/daily-report-edit-form.tsx +6 -0
  26. package/src/client/components/daily-report-list.tsx +88 -55
  27. package/src/client/components/daily-report-page.tsx +8 -3
  28. package/src/client/components/daily-report-resolved-content.tsx +5 -3
  29. package/src/client/components/report-views.spec.tsx +37 -2
  30. package/src/client/config-context.tsx +18 -0
  31. package/src/client/contexts/daily-report-action-context.spec.tsx +16 -0
  32. package/src/client/contexts/daily-report-action-context.tsx +75 -34
  33. package/src/client/contexts/daily-report-error-context.spec.tsx +66 -0
  34. package/src/client/contexts/daily-report-error-context.tsx +98 -0
  35. package/src/client/hooks/use-daily-report.spec.ts +50 -2
  36. package/src/client/hooks/use-daily-report.ts +30 -1
  37. package/src/client/streaming/daily-report-ids-stream-client.spec.ts +64 -1
  38. package/src/client/streaming/daily-report-ids-stream-client.ts +80 -2
  39. package/src/client.ts +1 -0
  40. package/src/server/handlers.action.spec.ts +44 -0
  41. package/src/server/handlers.ids-stream.spec.ts +53 -1
  42. package/src/server/handlers.ts +81 -5
  43. package/src/server/service.spec.ts +83 -7
  44. package/src/server/service.ts +60 -3
  45. package/src/server/test-helpers/handlers-config.ts +9 -1
  46. package/src/server.spec.ts +49 -0
  47. package/src/server.ts +7 -0
  48. package/src/shared/business-date.spec.ts +22 -1
  49. package/src/shared/business-date.ts +20 -0
  50. package/src/shared/comment-adapter.spec.ts +38 -0
  51. package/src/shared/comment-adapter.ts +9 -7
  52. package/src/shared/ids-stream.spec.ts +65 -0
  53. package/src/shared/ids-stream.ts +47 -0
  54. package/src/shared/sse-schema.ts +5 -0
  55. package/src/shared/text-utils.spec.ts +42 -0
  56. package/src/shared/text-utils.ts +8 -0
@@ -50,6 +50,13 @@ export type DailyReportIdsStreamClientOptions = {
50
50
  /** タイマー実装 (既定 setTimeout / clearTimeout)。 */
51
51
  setTimeoutFn?: (handler: () => void, ms: number) => ReturnType<typeof setTimeout>
52
52
  clearTimeoutFn?: (id: ReturnType<typeof setTimeout>) => void
53
+ /**
54
+ * 1 回の read が無音でいられる上限 (ms)。0 以下で無効。
55
+ * これを超えたら接続を half-open とみなして中断し、retryable として
56
+ * カーソル再開へ倒す。サーバーのハートビート間隔より必ず長く取る
57
+ * (生きているが遅いだけの全量クエリ待ちを誤って切らないため)。
58
+ */
59
+ readStallTimeoutMs?: number
53
60
  /** 警告ロガー (既定 console.warn)。 */
54
61
  warn?: (...args: unknown[]) => void
55
62
  /**
@@ -73,6 +80,16 @@ const MAX_RETRY_DELAY_MS = 30_000
73
80
  /** 連続失敗の上限。到達で failed へ落ち、手動 retry 待ちになる。 */
74
81
  const MAX_CONSECUTIVE_FAILURES = 6
75
82
 
83
+ /**
84
+ * 1 回の read が無音でいられる既定の上限 (ms)。
85
+ * サーバーのハートビート間隔 (15s) の 2 倍を取り、生きた接続を誤って切らない。
86
+ * 超過した read は half-open 接続とみなし retryable に倒す。
87
+ */
88
+ const DEFAULT_READ_STALL_TIMEOUT_MS = 30_000
89
+
90
+ /** read が stall ウォッチドッグに捕まったことを表す番兵。 */
91
+ const READ_STALL = Symbol("read-stall")
92
+
76
93
  /** 既定の API ベースパス (config-context の既定と一致させる)。 */
77
94
  const DEFAULT_API_BASE_PATH = "/daily_report/api"
78
95
 
@@ -89,6 +106,8 @@ export class DailyReportIdsStreamClient {
89
106
  private readonly setTimeoutFn: NonNullable<DailyReportIdsStreamClientOptions["setTimeoutFn"]>
90
107
  private readonly clearTimeoutFn: NonNullable<DailyReportIdsStreamClientOptions["clearTimeoutFn"]>
91
108
  private readonly warn: NonNullable<DailyReportIdsStreamClientOptions["warn"]>
109
+ /** 1 回の read が無音でいられる上限 (ms)。0 以下で無効。 */
110
+ private readonly readStallTimeoutMs: number
92
111
 
93
112
  /** reportHubId → アイテム。挿入順を保ち、再開の重複到着を吸収する。 */
94
113
  private readonly itemMap = new Map<number, DailyReportItem>()
@@ -148,6 +167,7 @@ export class DailyReportIdsStreamClient {
148
167
  this.setTimeoutFn = options.setTimeoutFn ?? ((handler, ms) => setTimeout(handler, ms))
149
168
  this.clearTimeoutFn = options.clearTimeoutFn ?? ((id) => clearTimeout(id))
150
169
  this.warn = options.warn ?? ((...args) => console.warn(...args))
170
+ this.readStallTimeoutMs = options.readStallTimeoutMs ?? DEFAULT_READ_STALL_TIMEOUT_MS
151
171
  this.publishCoalesceMs = options.publishCoalesceMs ?? DEFAULT_PUBLISH_COALESCE_MS
152
172
  if (typeof window !== "undefined") {
153
173
  // オフライン復帰でバックオフ待機を即時解除する
@@ -402,11 +422,32 @@ export class DailyReportIdsStreamClient {
402
422
  const abortController = new AbortController()
403
423
  this.activeAbortController = abortController
404
424
  try {
405
- const response = await this.fetchFn(this.buildStreamUrl(forceRefresh), {
425
+ // ヘッダー到着前 (再開経路は全量クエリ確定までヘッダーを返さない) にはハートビートが
426
+ // 流れないため、read より緩い上限 (3 倍 = 既定 90s。サーバーの requestTimeout 60s より
427
+ // 長い) で fetch 自体も見張る。ここで切れた場合も retryable としてカーソル再開する
428
+ const fetchPromise = this.fetchFn(this.buildStreamUrl(forceRefresh), {
406
429
  signal: abortController.signal,
407
430
  headers: { Accept: "application/x-ndjson" },
408
431
  cache: "no-store",
409
432
  })
433
+ let headerTimer: ReturnType<typeof setTimeout> | null = null
434
+ const response =
435
+ this.readStallTimeoutMs <= 0
436
+ ? await fetchPromise
437
+ : await Promise.race([
438
+ fetchPromise,
439
+ new Promise<typeof READ_STALL>((resolve) => {
440
+ headerTimer = this.setTimeoutFn(() => resolve(READ_STALL), this.readStallTimeoutMs * 3)
441
+ }),
442
+ ]).finally(() => {
443
+ if (headerTimer !== null) this.clearTimeoutFn(headerTimer)
444
+ })
445
+ if (response === READ_STALL) {
446
+ abortController.abort()
447
+ // 破棄した接続の解決/拒否を未処理のまま残さない
448
+ fetchPromise.catch(() => {})
449
+ return { kind: "retryable", error: new Error("Stream stalled: no response headers within timeout") }
450
+ }
410
451
  if (response.status === 401 || response.status === 403) {
411
452
  return { kind: "auth-required" }
412
453
  }
@@ -421,7 +462,15 @@ export class DailyReportIdsStreamClient {
421
462
  let sawIsLast = false
422
463
 
423
464
  while (true) {
424
- const { done, value } = await reader.read()
465
+ const chunk = await this.readWithStallGuard(reader)
466
+ if (chunk === READ_STALL) {
467
+ // 無音が続いた half-open 接続。中断してカーソル再開へ倒す
468
+ // (finally が activeAbortController を掃除する)
469
+ abortController.abort()
470
+ void reader.cancel().catch(() => {})
471
+ return { kind: "retryable", error: new Error("Stream stalled: no data within timeout") }
472
+ }
473
+ const { done, value } = chunk
425
474
  if (done) break
426
475
  // ネットワークチャンクは行境界と無関係に割れるため、末尾の欠け行を持ち越す
427
476
  const lines = (partialLine + decoder.decode(value, { stream: true })).split("\n")
@@ -486,6 +535,35 @@ export class DailyReportIdsStreamClient {
486
535
  }
487
536
  }
488
537
 
538
+ /**
539
+ * Reads one chunk, racing the read against a stall watchdog.
540
+ * 1 チャンクを読む処理。読み取りを stall ウォッチドッグと競争させる。
541
+ *
542
+ * サーバーが無音のまま接続を half-open で放置しても (NAT/プロキシのアイドル切断・
543
+ * VPN 断・スリープ)、`reader.read()` は永久に保留したままになる。これを検知するため
544
+ * read とタイマーを競争させ、無音が上限を超えたら番兵を返す。生きているが遅いだけの
545
+ * 全量クエリ待ちはサーバーのハートビート (空行) が read を解決するので誤検知しない。
546
+ * `readStallTimeoutMs <= 0` のときは素の read (タイマーを張らない・テスト用)。
547
+ *
548
+ * @returns 読み取り結果、または stall を表す番兵。read の reject はそのまま伝播する。
549
+ */
550
+ private async readWithStallGuard(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<ReadableStreamReadResult<Uint8Array> | typeof READ_STALL> {
551
+ if (this.readStallTimeoutMs <= 0) {
552
+ return reader.read()
553
+ }
554
+ let stallTimer: ReturnType<typeof setTimeout> | null = null
555
+ const stallPromise = new Promise<typeof READ_STALL>((resolve) => {
556
+ stallTimer = this.setTimeoutFn(() => resolve(READ_STALL), this.readStallTimeoutMs)
557
+ })
558
+ try {
559
+ return await Promise.race([reader.read(), stallPromise])
560
+ } finally {
561
+ if (stallTimer !== null) {
562
+ this.clearTimeoutFn(stallTimer)
563
+ }
564
+ }
565
+ }
566
+
489
567
  /**
490
568
  * Merges chunk items into the dedupe map.
491
569
  * チャンクのアイテム群を重複排除 Map へ取り込む処理。
package/src/client.ts CHANGED
@@ -16,6 +16,7 @@ export * from "./client/components/daily-report-resolved-content"
16
16
  export * from "./client/components/unread-indicator"
17
17
  export * from "./client/config-context"
18
18
  export * from "./client/contexts/daily-report-action-context"
19
+ export * from "./client/contexts/daily-report-error-context"
19
20
  export * from "./client/hooks/use-animated-number"
20
21
  export * from "./client/hooks/use-daily-report"
21
22
  export * from "./client/hooks/use-daily-report-comments"
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Action-endpoint tests: the dev-only clearCache gate.
3
+ * action エンドポイントのテスト: 開発専用 clearCache の封鎖ゲート。
4
+ */
5
+ import { describe, expect, it } from "vitest"
6
+ import { createDailyReportHandlers } from "./handlers"
7
+ import { makeHandlersFixture } from "./test-helpers/handlers-config"
8
+
9
+ /** POST /action の引数を組み立てる (FormData ボディ付き)。 */
10
+ const makeActionArgs = (intent: string) => {
11
+ const body = new FormData()
12
+ body.set("intent", intent)
13
+ return {
14
+ request: new Request("http://localhost/daily_report/api/action", { method: "POST", body }),
15
+ params: { endpoint: "action" } as Record<string, string | undefined>,
16
+ }
17
+ }
18
+
19
+ describe("action / clearCache の封鎖ゲート", () => {
20
+ it("既定 (未注入) では clearCache を 400 で拒否し、service.clearCache を呼ばない", async () => {
21
+ // 認証済みなら誰でも全ワーカーのキャッシュを無制限破棄できてしまうため既定封鎖
22
+ const { config, calls } = makeHandlersFixture()
23
+ const { api } = createDailyReportHandlers(config)
24
+ const res = await api.action(makeActionArgs("clearCache"))
25
+ expect(res.status).toBe(400)
26
+ expect(calls.clearCache).toBe(0)
27
+ })
28
+
29
+ it("enableDevCacheClear:true のときだけ clearCache を実行する", async () => {
30
+ const { config, calls } = makeHandlersFixture({ enableDevCacheClear: true })
31
+ const { api } = createDailyReportHandlers(config)
32
+ const res = await api.action(makeActionArgs("clearCache"))
33
+ expect(res.status).toBe(200)
34
+ expect(calls.clearCache).toBe(1)
35
+ })
36
+
37
+ it("未認証は clearCache 以前に 401 で弾く", async () => {
38
+ const { config, calls } = makeHandlersFixture({ user: null, enableDevCacheClear: true })
39
+ const { api } = createDailyReportHandlers(config)
40
+ const res = await api.action(makeActionArgs("clearCache"))
41
+ expect(res.status).toBe(401)
42
+ expect(calls.clearCache).toBe(0)
43
+ })
44
+ })
@@ -12,7 +12,7 @@
12
12
  * - **`Cache-Control: no-transform`** — compression ミドルウェアのバッファリング防止が
13
13
  * ストリーミングの成立条件そのもの。
14
14
  */
15
- import { describe, expect, it } from "vitest"
15
+ import { describe, expect, it, vi } from "vitest"
16
16
  import type { DailyReportIdsStreamChunkLine, DailyReportIdsStreamLine } from "../shared/ids-stream"
17
17
  import type { DailyReportItem } from "../shared/types"
18
18
  import { createDailyReportHandlers } from "./handlers"
@@ -360,3 +360,55 @@ describe("ids-stream / 中断", () => {
360
360
  expect(await response.text()).toBe("")
361
361
  })
362
362
  })
363
+
364
+ describe("ids-stream / ハートビート", () => {
365
+ it("次チャンク待ち (全量クエリ確定待ち) が長引く間は空行ハートビートを送る", async () => {
366
+ vi.useFakeTimers()
367
+ try {
368
+ // 全量クエリを保留させ、先頭ページ (200 件) 描画後に無音区間を作る
369
+ let releaseFull: () => void = () => {}
370
+ const fullGate = new Promise<void>((resolve) => {
371
+ releaseFull = resolve
372
+ })
373
+ const items = makeItems(400)
374
+ const { config } = makeHandlersFixture({
375
+ idsStreamItems: items,
376
+ idsStreamFirstPageItems: items.slice(0, 200),
377
+ idsStreamFullGate: fullGate,
378
+ })
379
+ const { api } = createDailyReportHandlers(config)
380
+ const response = await api.loader(makeApiArgs("ids-stream"))
381
+ const reader = (response.body as ReadableStream<Uint8Array>).getReader()
382
+ const decoder = new TextDecoder()
383
+
384
+ // 先頭ページの最初のチャンク (200 件、isLast 無し)
385
+ const first = await reader.read()
386
+ const firstLine = JSON.parse(decoder.decode(first.value).trim()) as DailyReportIdsStreamChunkLine
387
+ expect(firstLine.items).toHaveLength(200)
388
+ expect(firstLine.isLast).toBe(false)
389
+
390
+ // 全量ゲート未解放のまま 15s 経過 → 接続の生存を示す空行
391
+ const heartbeatRead = reader.read()
392
+ await vi.advanceTimersByTimeAsync(15_000)
393
+ const heartbeat = await heartbeatRead
394
+ expect(decoder.decode(heartbeat.value)).toBe("\n")
395
+
396
+ // 全量を解放すると残りが流れて isLast で閉じる (ハートビートが本体を壊していない)
397
+ releaseFull()
398
+ let sawLast = false
399
+ for (let i = 0; i < 50; i++) {
400
+ const { done, value } = await reader.read()
401
+ if (done) break
402
+ const text = decoder.decode(value).trim()
403
+ if (text === "") continue
404
+ for (const raw of text.split("\n")) {
405
+ const line = JSON.parse(raw) as DailyReportIdsStreamChunkLine
406
+ if (line.isLast) sawLast = true
407
+ }
408
+ }
409
+ expect(sawLast).toBe(true)
410
+ } finally {
411
+ vi.useRealTimers()
412
+ }
413
+ })
414
+ })
@@ -52,6 +52,19 @@ const IDS_STREAM_ERROR_CLIENT_MESSAGE = "stream interrupted by server error"
52
52
  /** 再開カーソルの営業日として受け付ける唯一の形式 (正規化済み YYYY-MM-DD)。 */
53
53
  const IDS_STREAM_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
54
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
+
55
68
  /**
56
69
  * Parses and validates resume-cursor query params (fail-closed).
57
70
  * 再開カーソルのクエリパラメータを検証付きで解釈する処理 (不正は fail-closed)。
@@ -131,6 +144,9 @@ const buildIdsStreamResponse = (cookie: string | null, lines: AsyncGenerator<Dai
131
144
  // フラグで決定的に識別し、通常イベントとして静かに畳む (ERROR ログにしない —
132
145
  // 全量クエリ中の離脱は日常イベントで、本物の送出失敗が雑音に埋もれる)
133
146
  let cancelled = false
147
+ // next() の解決はハートビートを跨いで保持する。ハートビートが先着したら空行を出し、
148
+ // 同じ next() を次の pull で await し直す (毎回 next() を呼び直すと二重取得になる)。
149
+ let pendingNext: Promise<IteratorResult<DailyReportIdsStreamChunkLine>> | null = null
134
150
  const stream = new ReadableStream<Uint8Array>({
135
151
  async pull(controller) {
136
152
  if (cancelled || request.signal.aborted) {
@@ -142,8 +158,36 @@ const buildIdsStreamResponse = (cookie: string | null, lines: AsyncGenerator<Dai
142
158
  }
143
159
  return
144
160
  }
161
+ let heartbeatTimer: ReturnType<typeof setTimeout> | null = null
145
162
  try {
146
- const { value, done } = await lines.next()
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
147
191
  if (done) {
148
192
  controller.close()
149
193
  return
@@ -160,6 +204,11 @@ const buildIdsStreamResponse = (cookie: string | null, lines: AsyncGenerator<Dai
160
204
  }
161
205
  controller.enqueue(encoder.encode(`${JSON.stringify(value)}\n`))
162
206
  } catch (error) {
207
+ if (heartbeatTimer !== null) {
208
+ clearTimeout(heartbeatTimer)
209
+ }
210
+ // next() が throw した = このイテレーターは畳む。保留参照を残さない
211
+ pendingNext = null
163
212
  if (cancelled || request.signal.aborted) {
164
213
  // 切断起因の enqueue/close 失敗はエラーではない
165
214
  return
@@ -469,6 +518,13 @@ export type DailyReportHandlersConfig = {
469
518
  streamKey: string
470
519
  /** 未ログイン時のリダイレクト先 (index.loader 用、既定 "/auth/login")。 */
471
520
  loginRedirectPath?: string
521
+ /**
522
+ * `intent=clearCache` (全サーバーキャッシュの一括破棄) を許可するか。既定 false。
523
+ * 開発用アフォーダンスであり、有効化すると認証済みユーザーなら誰でも全ワーカーの
524
+ * キャッシュを無制限に破棄できるため、本番では必ず無効のままにする
525
+ * (消費アプリは開発環境でのみ true を注入する)。
526
+ */
527
+ enableDevCacheClear?: boolean
472
528
  /** ロガー (既定は console ベース)。 */
473
529
  logger?: DailyReportLogger
474
530
  }
@@ -561,7 +617,9 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
561
617
  fullArrayPromise.catch(() => {})
562
618
 
563
619
  if (parsed.cursor) {
564
- // 再開接続: クライアントは高速先頭を受信済みなので全量確定を待つ (エラーは 500)
620
+ // 再開接続: クライアントは高速先頭を受信済みなので全量確定を待つ (エラーは 500)。
621
+ // 全量クエリはアプリ側の requestTimeout (既定 60s) で境界づけられ、確定後は
622
+ // スライスだけなので、接続確立後の無音はハートビート対象にならない
565
623
  return buildIdsStreamResponse(cookie, streamLinesFromFullArray(await fullArrayPromise, parsed.cursor), request, apiLogger)
566
624
  }
567
625
 
@@ -700,6 +758,11 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
700
758
  const clientTempId = formData.get("clientTempId") as string | null
701
759
 
702
760
  if (intent === "clearCache") {
761
+ // 開発専用アフォーダンス。既定は封鎖 (認証済みなら誰でも全ワーカーの
762
+ // キャッシュを無制限破棄できてしまうため、注入で明示的に開けた環境のみ許可)
763
+ if (!config.enableDevCacheClear) {
764
+ return jsonData({ error: "Invalid intent" }, { status: 400 })
765
+ }
703
766
  await service.clearCache()
704
767
  return jsonData({ status: "OK", intent: "clearCache" })
705
768
  }
@@ -807,9 +870,22 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
807
870
  if (!content) {
808
871
  return jsonData({ error: "Content required" }, { status: 400 })
809
872
  }
810
- const newComment = await service.addComment(userId, reportHubId, content, businessDate, clientTempId)
811
- const safeComment = { ...newComment, userId: newComment.userId }
812
- 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
+ }
813
889
  }
814
890
  case "deleteComment": {
815
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
+ })
@@ -1153,15 +1153,50 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1153
1153
  return updatedStatus
1154
1154
  }
1155
1155
 
1156
+ /**
1157
+ * Resolves the SSE recipient for comment events on a possibly-draft report.
1158
+ * 下書きの可能性がある日報のコメントイベントについて SSE の宛先を解決する処理。
1159
+ *
1160
+ * 下書きは所有者にしか見えないため、下書きラベルが付いている間はコメント本文も
1161
+ * 所有者だけに配信する (無指定だと本文が全ユーザーへブロードキャストされる)。
1162
+ * 公開済み (下書きラベル無し) は全員配信 = undefined を返す。
1163
+ *
1164
+ * @param hubId Parent hub id. 親日報の hub ID。
1165
+ * @param ownerUserId Hub owner's raw user id. 所有者の内部ユーザー ID。
1166
+ * @returns Recipient raw user id, or undefined to broadcast. 宛先 (全員配信は undefined)。
1167
+ */
1168
+ const resolveCommentRecipient = async (hubId: number, ownerUserId: number | null): Promise<number | undefined> => {
1169
+ // 所有者不明 (共有日報など) は従来どおり全員配信
1170
+ if (ownerUserId === null) return undefined
1171
+ const draftIds = await getDraftLabelJoinIds()
1172
+ const [rel] = await db
1173
+ .select<{ hubId: number }>({ hubId: DailyReportHub_Label.hubId })
1174
+ .top(1)
1175
+ .from(DailyReportHub_Label)
1176
+ .where(and(eq(DailyReportHub_Label.hubId, hubId), inArray(DailyReportHub_Label.labelId, draftIds)))
1177
+ return rel ? ownerUserId : undefined
1178
+ }
1179
+
1156
1180
  /**
1157
1181
  * Adds a comment to a daily report.
1158
1182
  * 日報にコメントを追加する。
1159
1183
  */
1160
1184
  const addComment = async (userId: number, reportHubId: number, content: string, businessDate: string | null, clientTempId: string): Promise<DailyReportCommentItem> => {
1161
- const [hub] = await db.select<{ sourceType: string }>({ sourceType: DailyReportHub.sourceType }).top(1).from(DailyReportHub).where(eq(DailyReportHub.id, reportHubId))
1162
- if (hub && hub.sourceType.toLowerCase() !== "internal") {
1185
+ const [hub] = await db
1186
+ .select<{ sourceType: string; deletedAt: Date | string | null; userId: number | null }>({ sourceType: DailyReportHub.sourceType, deletedAt: DailyReportHub.deletedAt, userId: DailyReportHub.userId })
1187
+ .top(1)
1188
+ .from(DailyReportHub)
1189
+ .where(eq(DailyReportHub.id, reportHubId))
1190
+ // 存在しない / 論理削除済みの日報にはコメントを付けない (以前は hub 未取得でも
1191
+ // フォールスルーして孤児コメントを INSERT していた)
1192
+ if (!hub || hub.deletedAt) {
1193
+ throw new Error("Not Found")
1194
+ }
1195
+ if (hub.sourceType.toLowerCase() !== "internal") {
1163
1196
  throw new Error("Comments are restricted for external daily report sources")
1164
1197
  }
1198
+ // 下書きなら SSE を所有者宛に絞る (本文の全員配信を防ぐ)
1199
+ const recipientRawUserId = await resolveCommentRecipient(reportHubId, hub.userId)
1165
1200
 
1166
1201
  const [inserted] = await db
1167
1202
  .insert<DailyReportCommentRow>(DailyReportCommentModel)
@@ -1206,6 +1241,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1206
1241
  reportHubId: reportHubId,
1207
1242
  comment: commentItem,
1208
1243
  clientTempId,
1244
+ recipientRawUserId,
1209
1245
  }),
1210
1246
  "addComment",
1211
1247
  )
@@ -1233,6 +1269,15 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1233
1269
  if (comments[0].userId !== userId) {
1234
1270
  throw new Error("Unauthorized")
1235
1271
  }
1272
+ // 渡された reportHubId がコメントの実際の所属と一致することを確認する。
1273
+ // 不一致のまま進むと、無関係な日報のキャッシュ無効化と SSE 配信を引き起こす
1274
+ if (comments[0].hubId !== reportHubId) {
1275
+ throw new Error("Not Found")
1276
+ }
1277
+
1278
+ // 下書きなら削除イベントも所有者宛に絞る (comment-add と対称)
1279
+ const [hubForDelete] = await db.select<{ userId: number | null }>({ userId: DailyReportHub.userId }).top(1).from(DailyReportHub).where(eq(DailyReportHub.id, reportHubId))
1280
+ const recipientRawUserId = await resolveCommentRecipient(reportHubId, hubForDelete?.userId ?? null)
1236
1281
 
1237
1282
  await db.delete(DailyReportCommentModel).where(eq(DailyReportCommentModel.id, commentId))
1238
1283
 
@@ -1254,6 +1299,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1254
1299
  reportHubId: reportHubId,
1255
1300
  commentId,
1256
1301
  clientTempId,
1302
+ recipientRawUserId,
1257
1303
  }),
1258
1304
  "deleteComment",
1259
1305
  )
@@ -1504,9 +1550,20 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1504
1550
  }
1505
1551
  })
1506
1552
 
1507
- // キャッシュ無効化
1553
+ // キャッシュ無効化: 一覧プレビューは詳細キャッシュから引くため、詳細を全プロセス横断で落とす。
1554
+ // 以前は読み取りに使われないプレフィックスを無効化し epoch も上げていなかったため、
1555
+ // 編集後の件名/本文が他ワーカーで古いまま配信されていた
1556
+ sqlResultCache.invalidate(`daily-report:detail:${reportHubId}`)
1508
1557
  sqlResultCache.invalidatePrefix(`daily-report:detail:${reportHubId}:user:`)
1509
1558
  sqlResultCache.invalidatePrefix(DAILY_REPORT_BUSINESS_DATE_CACHE_PREFIX)
1559
+ await incrementRedisEpoch(`${DAILY_REPORT_DETAIL_EPOCH_PREFIX}${reportHubId}`)
1560
+ // 編集内容は全ユーザーの一覧に映るため、営業日別一覧は全ユーザー分 + 全ワーカー横断で
1561
+ // 無効化する (addComment と同じ流儀。編集者本人のキーだけでは他者へ古い件名が残る)
1562
+ const editedBusinessDate = normalizeBusinessDateKey(formatDateValue(report[0].businessDate, "YYYY-MM-DD"))
1563
+ if (editedBusinessDate) {
1564
+ sqlResultCache.invalidatePrefix(`daily-report:date:${editedBusinessDate}:user:`)
1565
+ await incrementRedisEpoch(`${DAILY_REPORT_DATE_EPOCH_PREFIX}${editedBusinessDate}`)
1566
+ }
1510
1567
 
1511
1568
  const fullDetail = await getDailyReportDetailById(reportHubId, userId, { forceRefresh: true })
1512
1569
  if (fullDetail) {