@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.
- package/dist/client.d.mts +76 -6
- package/dist/client.d.ts +76 -6
- package/dist/client.js +5 -5
- package/dist/client.js.map +1 -1
- package/dist/client.mjs +5 -5
- package/dist/client.mjs.map +1 -1
- package/dist/index.d.mts +40 -2
- package/dist/index.d.ts +40 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/server.d.mts +13 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +8 -7
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +8 -7
- package/dist/server.mjs.map +1 -1
- package/dist/{sse-schema-Df49KA7B.d.mts → sse-schema-BiUnufrI.d.mts} +12 -0
- package/dist/{sse-schema-RHckD7TS.d.ts → sse-schema-YZh7Pz7i.d.ts} +12 -0
- package/dist/styles/daily-report.standalone.css +1 -1
- package/package.json +1 -1
- package/src/client/components/daily-report-comment-item.tsx +6 -1
- package/src/client/components/daily-report-detail-list.tsx +65 -34
- package/src/client/components/daily-report-edit-form.tsx +6 -0
- package/src/client/components/daily-report-list.tsx +88 -55
- package/src/client/components/daily-report-page.tsx +8 -3
- package/src/client/components/daily-report-resolved-content.tsx +5 -3
- package/src/client/components/report-views.spec.tsx +37 -2
- package/src/client/config-context.tsx +18 -0
- package/src/client/contexts/daily-report-action-context.spec.tsx +16 -0
- package/src/client/contexts/daily-report-action-context.tsx +75 -34
- package/src/client/contexts/daily-report-error-context.spec.tsx +66 -0
- package/src/client/contexts/daily-report-error-context.tsx +98 -0
- package/src/client/hooks/use-daily-report.spec.ts +50 -2
- package/src/client/hooks/use-daily-report.ts +30 -1
- package/src/client/streaming/daily-report-ids-stream-client.spec.ts +64 -1
- package/src/client/streaming/daily-report-ids-stream-client.ts +80 -2
- package/src/client.ts +1 -0
- package/src/server/handlers.action.spec.ts +44 -0
- package/src/server/handlers.ids-stream.spec.ts +53 -1
- package/src/server/handlers.ts +81 -5
- package/src/server/service.spec.ts +83 -7
- package/src/server/service.ts +60 -3
- package/src/server/test-helpers/handlers-config.ts +9 -1
- package/src/server.spec.ts +49 -0
- package/src/server.ts +7 -0
- package/src/shared/business-date.spec.ts +22 -1
- package/src/shared/business-date.ts +20 -0
- package/src/shared/comment-adapter.spec.ts +38 -0
- package/src/shared/comment-adapter.ts +9 -7
- package/src/shared/ids-stream.spec.ts +65 -0
- package/src/shared/ids-stream.ts +47 -0
- package/src/shared/sse-schema.ts +5 -0
- package/src/shared/text-utils.spec.ts +42 -0
- 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
|
-
|
|
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
|
|
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
|
+
})
|
package/src/server/handlers.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
+
})
|
package/src/server/service.ts
CHANGED
|
@@ -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
|
|
1162
|
-
|
|
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) {
|