@aiquants/daily-report 0.20.0 → 0.21.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 (116) hide show
  1. package/README.md +229 -8
  2. package/dist/client.d.mts +201 -739
  3. package/dist/client.d.ts +201 -739
  4. package/dist/client.js +5 -6
  5. package/dist/client.mjs +5 -6
  6. package/dist/index.d.mts +5 -197
  7. package/dist/index.d.ts +5 -197
  8. package/dist/index.js +1 -2
  9. package/dist/index.mjs +1 -2
  10. package/dist/logger-D3krZrNK.d.mts +0 -14
  11. package/dist/logger-D3krZrNK.d.ts +0 -14
  12. package/dist/server.d.mts +114 -744
  13. package/dist/server.d.ts +114 -744
  14. package/dist/server.js +1 -2
  15. package/dist/server.mjs +1 -2
  16. package/dist/{sse-schema-ChSFm-4q.d.mts → sse-schema-DCxpdETM.d.mts} +1106 -1090
  17. package/dist/{sse-schema-BdUGThiz.d.ts → sse-schema-DoQw8_om.d.ts} +1106 -1090
  18. package/dist/types-CqgYQgbH.d.mts +0 -34
  19. package/dist/types-CqgYQgbH.d.ts +0 -34
  20. package/package.json +9 -10
  21. package/dist/client.js.map +0 -1
  22. package/dist/client.mjs.map +0 -1
  23. package/dist/index.js.map +0 -1
  24. package/dist/index.mjs.map +0 -1
  25. package/dist/server.js.map +0 -1
  26. package/dist/server.mjs.map +0 -1
  27. package/src/client/components/business-day-thumb-overlay.tsx +0 -19
  28. package/src/client/components/daily-report-attachment-indicator.spec.tsx +0 -51
  29. package/src/client/components/daily-report-attachment-indicator.tsx +0 -45
  30. package/src/client/components/daily-report-attachment-list.spec.tsx +0 -94
  31. package/src/client/components/daily-report-attachment-list.tsx +0 -112
  32. package/src/client/components/daily-report-comment-item.tsx +0 -89
  33. package/src/client/components/daily-report-comment-section.tsx +0 -166
  34. package/src/client/components/daily-report-detail-list.tsx +0 -842
  35. package/src/client/components/daily-report-edit-form.tsx +0 -87
  36. package/src/client/components/daily-report-ids-stream-status.spec.tsx +0 -93
  37. package/src/client/components/daily-report-ids-stream-status.tsx +0 -67
  38. package/src/client/components/daily-report-list.tsx +0 -1232
  39. package/src/client/components/daily-report-page.spec.tsx +0 -119
  40. package/src/client/components/daily-report-page.tsx +0 -140
  41. package/src/client/components/daily-report-resize-panels.spec.tsx +0 -307
  42. package/src/client/components/daily-report-resolved-content.tsx +0 -207
  43. package/src/client/components/daily-report-scroll-restoration.spec.tsx +0 -422
  44. package/src/client/components/report-views.spec.tsx +0 -375
  45. package/src/client/components/unread-indicator.tsx +0 -13
  46. package/src/client/config-context.tsx +0 -184
  47. package/src/client/contexts/daily-report-action-context.spec.tsx +0 -433
  48. package/src/client/contexts/daily-report-action-context.tsx +0 -1147
  49. package/src/client/contexts/daily-report-error-context.spec.tsx +0 -66
  50. package/src/client/contexts/daily-report-error-context.tsx +0 -98
  51. package/src/client/hooks/use-animated-number.spec.ts +0 -103
  52. package/src/client/hooks/use-animated-number.ts +0 -69
  53. package/src/client/hooks/use-daily-report-comments.ts +0 -73
  54. package/src/client/hooks/use-daily-report-sse-connection.ts +0 -86
  55. package/src/client/hooks/use-daily-report.spec.ts +0 -546
  56. package/src/client/hooks/use-daily-report.ts +0 -549
  57. package/src/client/hooks/use-dynamic-viewport-height.ts +0 -127
  58. package/src/client/hooks/use-responsive-layout.spec.ts +0 -74
  59. package/src/client/hooks/use-responsive-layout.ts +0 -63
  60. package/src/client/route-helpers.spec.ts +0 -100
  61. package/src/client/route-helpers.ts +0 -61
  62. package/src/client/streaming/daily-report-ids-stream-client.spec.ts +0 -910
  63. package/src/client/streaming/daily-report-ids-stream-client.ts +0 -730
  64. package/src/client/streaming/daily-report-ids-stream-session.spec.ts +0 -401
  65. package/src/client/streaming/daily-report-ids-stream-session.ts +0 -319
  66. package/src/client/ui/button.tsx +0 -42
  67. package/src/client/ui/cn.ts +0 -14
  68. package/src/client/ui/input.tsx +0 -21
  69. package/src/client/ui/label.tsx +0 -16
  70. package/src/client/ui/switch.tsx +0 -19
  71. package/src/client/ui/tabs.tsx +0 -40
  72. package/src/client/ui/textarea.tsx +0 -19
  73. package/src/client/utils/constants.spec.ts +0 -96
  74. package/src/client/utils/constants.ts +0 -84
  75. package/src/client.ts +0 -28
  76. package/src/index.ts +0 -11
  77. package/src/lucide-icons.d.ts +0 -6
  78. package/src/server/authz.spec.ts +0 -57
  79. package/src/server/authz.ts +0 -99
  80. package/src/server/cache.spec.ts +0 -556
  81. package/src/server/cache.ts +0 -303
  82. package/src/server/etag.ts +0 -18
  83. package/src/server/external-source.ts +0 -60
  84. package/src/server/handlers.action.spec.ts +0 -44
  85. package/src/server/handlers.attachment.spec.ts +0 -470
  86. package/src/server/handlers.ids-stream.spec.ts +0 -618
  87. package/src/server/handlers.ts +0 -1638
  88. package/src/server/handlers.unauthorized-cookie.spec.ts +0 -187
  89. package/src/server/handlers.visibility.spec.ts +0 -774
  90. package/src/server/ports.ts +0 -222
  91. package/src/server/response.ts +0 -37
  92. package/src/server/schema.spec.ts +0 -30
  93. package/src/server/schema.ts +0 -344
  94. package/src/server/service.spec.ts +0 -1130
  95. package/src/server/service.ts +0 -2045
  96. package/src/server/sse-reader.spec.ts +0 -302
  97. package/src/server/sse-reader.ts +0 -267
  98. package/src/server/test-helpers/handlers-config.ts +0 -321
  99. package/src/server/transaction-retry.spec.ts +0 -63
  100. package/src/server/visibility.spec.ts +0 -403
  101. package/src/server/visibility.ts +0 -373
  102. package/src/server.spec.ts +0 -160
  103. package/src/server.ts +0 -131
  104. package/src/shared/business-date.spec.ts +0 -82
  105. package/src/shared/business-date.ts +0 -104
  106. package/src/shared/comment-adapter.spec.ts +0 -38
  107. package/src/shared/comment-adapter.ts +0 -80
  108. package/src/shared/ids-stream.spec.ts +0 -154
  109. package/src/shared/ids-stream.ts +0 -279
  110. package/src/shared/logger.ts +0 -47
  111. package/src/shared/sse-schema.spec.ts +0 -50
  112. package/src/shared/sse-schema.ts +0 -198
  113. package/src/shared/text-utils.spec.ts +0 -42
  114. package/src/shared/text-utils.ts +0 -65
  115. package/src/shared/types.ts +0 -109
  116. package/src/styles/standalone.entry.css +0 -51
@@ -1,2045 +0,0 @@
1
- /**
2
- * Data-access service for daily reports (drizzle mssql), fully DI-configured.
3
- * 日報データアクセスサービス (drizzle mssql)。依存はすべて DI で注入する。
4
- *
5
- * キャッシュキー・無効化順序・SSE publish 順序を含むデータアクセスロジックの中核。
6
- */
7
-
8
- import { runWithTransactionRetry, type TransactionRetryOptions } from "@aiquants/mssql"
9
- import { aliasedTable, and, asc, desc, eq, getColumns, inArray, isNull, or, sql } from "drizzle-orm"
10
- import { normalizeBusinessDateKey } from "../shared/business-date"
11
- import { compareIdsStreamOrder, isIdsStreamOrdered } from "../shared/ids-stream"
12
- import { createLogger, type DailyReportLogger, LogLevel } from "../shared/logger"
13
- import { commentAddMessageSchema, commentDeleteMessageSchema, reportCreateMessageSchema, reportDeleteMessageSchema, reportPublishMessageSchema, reportUpdateMessageSchema, statusUpdateMessageSchema } from "../shared/sse-schema"
14
- import type { DailyReportAttachmentSummary, DailyReportDetail, DailyReportExternalComment, DailyReportInterviewer, DailyReportItem, DailyReportLabelDef, DailyReportPostedComment } from "../shared/types"
15
- import type { EpochStore, SqlResultCache, SqlResultCacheQueryOptions } from "./cache"
16
- import type { DailyReportExternalSource } from "./external-source"
17
- import type { DailyReportEncodeUserId, DailyReportIdCodec, DailyReportRedisProvider, DailyReportResolveUserId } from "./ports"
18
- import type { DailyReportTables, DailyReportUserTable } from "./schema"
19
- import { buildVisibleSourceTypeCondition, computeViewerScopeDigest, DailyReportViewerScope, deniesEverySourceType, isSourceTypeVisible, type VisibleSourceTypeSet } from "./visibility"
20
-
21
- export type { SqlResultCacheQueryOptions }
22
-
23
- // ---- 行型 (drizzle $inferSelect の構造的置き換え) ----
24
-
25
- /** DailyReportHub の行型。 */
26
- export type DailyReportHubRow = {
27
- id: number
28
- sourceType: string
29
- sourceId: string
30
- sourceIdNum: number | null
31
- businessDate: Date | string | null
32
- userId: number | null
33
- title: string | null
34
- summary: string | null
35
- createdAt: Date | string
36
- createdBy: string
37
- updatedAt: Date | string
38
- updatedBy: string
39
- deletedAt: Date | string | null
40
- deletedBy: string | null
41
- }
42
-
43
- /** DailyReportInternal の行型。 */
44
- export type DailyReportInternalRow = {
45
- hubId: number
46
- body: string | null
47
- metadata: string | null
48
- createdAt: Date | string
49
- createdBy: string
50
- updatedAt: Date | string
51
- updatedBy: string
52
- }
53
-
54
- /** DailyReportUserStatus の行型。 */
55
- export type DailyReportUserStatusRow = {
56
- hubId: number
57
- userId: number
58
- isRead: boolean
59
- isStarred: boolean
60
- createdAt: Date | string
61
- createdBy: string
62
- updatedAt: Date | string
63
- updatedBy: string
64
- }
65
-
66
- /** 投稿コメント行 (物理テーブル DailyReportComment) の行型。 */
67
- export type DailyReportCommentRow = {
68
- id: number
69
- hubId: number
70
- userId: number
71
- body: string
72
- createdAt: Date | string
73
- createdBy: string
74
- updatedAt: Date | string
75
- updatedBy: string
76
- }
77
-
78
- // ---- 必要最小の drizzle ビルダ形 (公開契約はサービス関数のシグネチャで厳格化) ----
79
-
80
- type Rows<T> = PromiseLike<T[]>
81
- interface SelectChain<T> extends Rows<T> {
82
- from(t: unknown): SelectChain<T>
83
- innerJoin(t: unknown, on: unknown): SelectChain<T>
84
- leftJoin(t: unknown, on: unknown): SelectChain<T>
85
- where(cond: unknown): SelectChain<T>
86
- orderBy(...cols: unknown[]): SelectChain<T>
87
- top(n: number): SelectChain<T>
88
- }
89
- interface InsertChain<T> {
90
- output(): { values(v: unknown): PromiseLike<T[]> }
91
- values(v: unknown): PromiseLike<unknown>
92
- }
93
- interface UpdateChain<T> {
94
- set(v: unknown): {
95
- where(cond: unknown): PromiseLike<unknown>
96
- output(): { where(cond: unknown): PromiseLike<T[]> }
97
- }
98
- }
99
- interface DeleteChain {
100
- where(cond: unknown): PromiseLike<unknown>
101
- }
102
-
103
- /** サービスが要求する drizzle mssql データベースの最小面 (トランザクション込み)。 */
104
- export interface DailyReportDb {
105
- select<T = Record<string, unknown>>(fields?: unknown): SelectChain<T>
106
- insert<T = Record<string, unknown>>(t: unknown): InsertChain<T>
107
- update<T = Record<string, unknown>>(t: unknown): UpdateChain<T>
108
- delete(t: unknown): DeleteChain
109
- transaction<T>(fn: (tx: DailyReportDb) => Promise<T>, config?: unknown): Promise<T>
110
- }
111
-
112
- /** getColumns へ構造型テーブルを渡すための境界キャスト。 */
113
- const cols = (t: unknown) => getColumns(t as Parameters<typeof getColumns>[0])
114
-
115
- // ---- サービス設定 ----
116
-
117
- export type DailyReportServiceConfig = {
118
- /** drizzle mssql データベースハンドル。 */
119
- db: DailyReportDb
120
- /** 日報 7 テーブル (アプリ既存モデルまたは defineDailyReportSchema 生成物)。`attachment` のみ任意。 */
121
- tables: DailyReportTables
122
- /** 表示名解決に使う外部ユーザーテーブル ({ id, displayName })。 */
123
- userTable: DailyReportUserTable
124
- /** 外部ユーザー ID → 内部数値 ID の解決ポート。 */
125
- resolveUserId: DailyReportResolveUserId
126
- /** 内部数値 ID の難読化ポート。 */
127
- encodeUserId: DailyReportEncodeUserId
128
- /** SSE publish / epoch 用 redis (省略時は SSE publish スキップ・epoch 無効)。 */
129
- redis?: DailyReportRedisProvider
130
- /** レガシー等の外部ソースアダプタ群。 */
131
- externalSources?: DailyReportExternalSource[]
132
- /** SQL 結果キャッシュ (facade が生成して注入)。 */
133
- cache: SqlResultCache
134
- /** クロスワーカー epoch ストア (facade が生成して注入)。 */
135
- epochs: EpochStore
136
- /**
137
- * Retry policy for transactions SQL Server chose as the deadlock victim. See `@aiquants/mssql`.
138
- * サーバがロールバックしたトランザクションの再試行方針。
139
- *
140
- * 鍵の範囲が重なる並行操作はデッドロックしうる。犠牲に選ばれた側はサーバによって
141
- * ロールバックされる — 何もコミットされていないので、書き直す再実行が正しい。既定で 3 回試す。
142
- * `{ attempts: 1 }` で再試行しない。
143
- */
144
- transactionRetry?: TransactionRetryOptions
145
- /**
146
- * 下書きラベル名 (単一または配列で指定可能)。
147
- * 消費アプリ側で自 DB のラベル名や作成区分の候補名 ("下書き", "DRAFT" など) を注入できる。
148
- */
149
- draftLabelName?: string
150
- draftLabelNames?: string[]
151
- /** SSE Redis Stream キー (既定 "daily-report:sse-stream")。 */
152
- streamKey?: string
153
- /** SSE Stream の MAXLEN (既定 10000)。 */
154
- streamMaxLen?: number
155
- /** IDs 一覧キャッシュ TTL (既定 180,000ms)。 */
156
- idsTtlMs?: number
157
- /** 営業日別キャッシュ TTL (既定 300,000ms)。 */
158
- businessDateTtlMs?: number
159
- /** 外部 ID → 内部 ID のプロセス内キャッシュを無効化 (テスト用)。 */
160
- disableUserIdCache?: boolean
161
- /**
162
- * 添付 ID の難読化コーデック。
163
- *
164
- * 任意にしているのは、添付テーブルを注入しない消費アプリと既存テストのフィクスチャを
165
- * 壊さないため。未注入なら添付一覧は常に空になる (縮退動作)。
166
- * ユーザー ID 用のコーデックとは **別インスタンス**を渡すこと。同一だと
167
- * レスポンス中の `userId` トークンがそのまま添付トークンとして解釈できてしまう。
168
- */
169
- attachmentIdCodec?: DailyReportIdCodec
170
- /** ロガー (既定は console ベース)。 */
171
- logger?: DailyReportLogger
172
- }
173
-
174
- /** createDailyReportService の返却型。 */
175
- export type DailyReportService = ReturnType<typeof createDailyReportService>
176
-
177
- /**
178
- * Creates the daily-report data-access service bound to the injected dependencies.
179
- * 注入された依存に束縛された日報データアクセスサービスを生成する処理。
180
- */
181
- type DatePattern = "YYYY-MM-DD HH:mm:ss" | "YYYY-MM-DD"
182
-
183
- /**
184
- * Formats date-like values to a given pattern using local-time fields (no Intl allocation).
185
- * 日付相当の値をローカル時刻フィールドで指定パターンへ整形する処理 (Intl 非生成)。
186
- *
187
- * ローカル時刻フィールドの手組み整形。旧実装 (ja-JP ロケール + 2-digit toLocale*) と出力互換。
188
- * ❗ toLocale* 系へ戻さないこと: あれは呼び出しごとに Intl.DateTimeFormat を生成し
189
- * (実測 約 66μs/呼)、全量 ids の 227k 行 map で 15〜20 秒の同期 CPU 洪水になる。
190
- * business-date / SSE の全応答が人質化した実害バグの根本原因だった。
191
- * モジュールスコープの export であり、バレル (src/server.ts の `export *`) 経由で
192
- * `@aiquants/daily-report/server` の公開 API の一部になる (テストからの直接検証と
193
- * ホスト側での汎用日時整形の再利用を意図した、意図的な公開)。
194
- * 1000 年未満の年は旧実装 (年ゼロ詰めなし) から意図的に乖離し、ISO 準拠の 4 桁ゼロ詰めで出す
195
- * (負の年は符号を除いた絶対値を 4 桁詰め。DB の date/datetime からは到達しない文字列入力のみの領域)。
196
- */
197
- export const formatDateValue = (value: Date | string | null | undefined, pattern: DatePattern): string | null => {
198
- // null / undefined / 空文字は null に正規化
199
- if (!value) {
200
- return null
201
- }
202
-
203
- // Date 以外 (文字列) は Date コンストラクタで解釈
204
- const dateValue = value instanceof Date ? value : new Date(value)
205
- if (Number.isNaN(dateValue.getTime())) {
206
- // 解釈不能な文字列は原文のまま返す (旧実装と同一の不変挙動)
207
- return typeof value === "string" ? value : null
208
- }
209
-
210
- // 年月日をゼロ詰めで組み立てる (負の年は符号を除いた絶対値を 4 桁詰め = ISO 8601 拡張表記に整合。
211
- // String(-5).padStart は "00-5" と符号ごと詰めてしまうため符号を分離する)
212
- const year = dateValue.getFullYear()
213
- const yyyy = year < 0 ? `-${String(-year).padStart(4, "0")}` : String(year).padStart(4, "0")
214
- const mm = String(dateValue.getMonth() + 1).padStart(2, "0")
215
- const dd = String(dateValue.getDate()).padStart(2, "0")
216
- if (pattern === "YYYY-MM-DD") {
217
- return `${yyyy}-${mm}-${dd}`
218
- }
219
- // 時分秒付きパターン
220
- const hh = String(dateValue.getHours()).padStart(2, "0")
221
- const mi = String(dateValue.getMinutes()).padStart(2, "0")
222
- const ss = String(dateValue.getSeconds()).padStart(2, "0")
223
- return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss}`
224
- }
225
-
226
- export function createDailyReportService(config: DailyReportServiceConfig) {
227
- const { db, tables, userTable: users, resolveUserId, encodeUserId, redis, cache: sqlResultCache, epochs } = config
228
- const { hub: DailyReportHub, internal: DailyReportInternal, comment: DailyReportCommentModel, label: DailyReportLabel, hubLabel: DailyReportHub_Label, userStatus: DailyReportUserStatus } = tables
229
- const externalSources = config.externalSources ?? []
230
- const draftLabelNames = config.draftLabelNames && config.draftLabelNames.length > 0 ? config.draftLabelNames : [config.draftLabelName ?? "下書き"]
231
- const draftLabelName = draftLabelNames[0] ?? "下書き"
232
- const logger = config.logger ?? createLogger(LogLevel.INFO, "[DailyReportService]")
233
- /**
234
- * 全トランザクションの唯一の入口。サーバがロールバックした場合だけやり直す。
235
- *
236
- * ⚠️ **閉包を DB の外へ波及させないこと。** 再実行は毎回はじめから走るため、ロールバックで元に
237
- * 戻らない副作用 (キャッシュ無効化・epoch 更新・SSE 配信) を閉包へ入れてはならない。現在それらは
238
- * すべてトランザクションの**後**に置かれている。
239
- */
240
- // 閉包を capture で包む。包まないと ORM が 1205 を EABORT へ置き換えた後しか見えず、
241
- // デッドロックだと判定できなくなる
242
- const inTransaction = <T>(fn: (tx: DailyReportDb) => Promise<T>): Promise<T> => runWithTransactionRetry((capture) => db.transaction((tx) => capture(() => fn(tx))), config.transactionRetry)
243
-
244
- const DAILY_REPORT_IDS_CACHE_KEY = "daily-report:ids"
245
- const DAILY_REPORT_IDS_TTL_MS = config.idsTtlMs ?? 180_000
246
- const DAILY_REPORT_IDS_EPOCH_KEY = "daily-report:ids:epoch"
247
-
248
- const DAILY_REPORT_BUSINESS_DATE_CACHE_PREFIX = "daily-report:business-date:"
249
- const DAILY_REPORT_BUSINESS_DATE_TTL_MS = config.businessDateTtlMs ?? 300_000
250
-
251
- /** 営業日キャッシュのクロスワーカー epoch プレフィックス */
252
- const DAILY_REPORT_DATE_EPOCH_PREFIX = "daily-report:date-epoch:"
253
- /** レポート詳細キャッシュのクロスワーカー epoch プレフィックス */
254
- const DAILY_REPORT_DETAIL_EPOCH_PREFIX = "daily-report:detail-epoch:"
255
-
256
- /**
257
- * SSE ワイヤ形式の版。埋め込み detail スキーマの互換を破る変更ごとに上げる。
258
- *
259
- * ❗ SSE の実体は配備を跨いで残る永続 Redis Stream (MAXLEN トリムのみ・時間上限なし)。
260
- * スキーマの必須キーを変えると、旧形式エントリが再接続 catch-up の safeParse で全滅する。
261
- * 版をキーへ焼き込むことで旧エントリは**構造的に到達不能**になり、スキーマに寿命無期限の
262
- * 互換シムを飼う必要がなくなる。設定キー (env 上書き含む) にも接尾辞を適用する —
263
- * 消費側の設定が版分離を黙って無効化できてはならない。
264
- * 旧版のストリームは xAdd が止まりトリムも走らないため残置される (手動 DEL 可)。
265
- * v2: comments/commentItems → externalComments/postedComments の改名 (2026-08)。
266
- */
267
- const DAILY_REPORT_SSE_WIRE_VERSION = "v2"
268
- const DAILY_REPORT_SSE_STREAM_KEY = `${config.streamKey ?? "daily-report:sse-stream"}:${DAILY_REPORT_SSE_WIRE_VERSION}`
269
- const DAILY_REPORT_SSE_STREAM_MAXLEN = config.streamMaxLen ?? 10000
270
-
271
- const incrementRedisEpoch = (key: string) => epochs.incrementEpoch(key)
272
-
273
- /**
274
- * Binds the shared visibility condition builder to this service's hub table.
275
- * 共有の可視性条件ビルダーを本サービスの hub テーブルへ束縛したもの。
276
- *
277
- * 三値の意味と fail-close の方向は {@link buildVisibleSourceTypeCondition} が唯一の真実源。
278
- */
279
- const visibleSourceTypeCondition = (visible: VisibleSourceTypeSet) => buildVisibleSourceTypeCondition(DailyReportHub.sourceType, visible)
280
-
281
- // ---- キャッシュキー生成 (ダイジェスト折り込みと無効化接頭辞の単一の真実源) ----
282
- // ❗ ユーザー別キャッシュキーは必ずここから作る。可視集合ダイジェストの付け忘れ (= 別権限へ
283
- // 古い結果を配信) と、無効化時の取りこぼしを構造的に防ぐ。接頭辞の末尾 ":" は
284
- // ユーザー ID の前方一致衝突 (1 と 10) を避けるため必須。
285
-
286
- /** ID 一覧キャッシュのキー (ユーザー + 可視集合ダイジェスト)。 */
287
- const idsCacheKey = (userId: number, digest: string): string => `${DAILY_REPORT_IDS_CACHE_KEY}:user:${userId}:st:${digest}`
288
- /** ID 一覧キャッシュの当該ユーザー全ダイジェスト分の接頭辞。 */
289
- const idsUserPrefix = (userId: number): string => `${DAILY_REPORT_IDS_CACHE_KEY}:user:${userId}:`
290
- /** 営業日別キャッシュのキー (営業日 + ユーザー + 可視集合ダイジェスト)。 */
291
- const dateCacheKey = (normalizedBusinessDate: string, userId: number, digest: string): string => `daily-report:date:${normalizedBusinessDate}:user:${userId}:st:${digest}`
292
- /** 営業日別キャッシュの当該ユーザー全ダイジェスト分の接頭辞。 */
293
- const dateUserPrefix = (normalizedBusinessDate: string, userId: number): string => `daily-report:date:${normalizedBusinessDate}:user:${userId}:`
294
- /** 詳細キャッシュのキー (hub + ユーザー + 可視集合ダイジェスト)。 */
295
- const detailCacheKey = (reportHubId: number, userId: number, digest: string): string => `daily-report:detail:${reportHubId}:user:${userId}:st:${digest}`
296
- /** 詳細キャッシュの当該ユーザー全ダイジェスト分の接頭辞。 */
297
- const detailUserPrefix = (reportHubId: number, userId: number): string => `daily-report:detail:${reportHubId}:user:${userId}:`
298
-
299
- /**
300
- * Publishes a message to the SSE Redis Stream.
301
- * SSE 用の Redis Stream にメッセージを追加する。
302
- */
303
- const publishToSseStream = async (message: Record<string, unknown>, callerName: string): Promise<void> => {
304
- const client = await redis?.getClient()
305
- if (!client) {
306
- logger.warn(`[SSE] Redis client unavailable (${callerName})`)
307
- return
308
- }
309
- const publishStartMs = Date.now()
310
- try {
311
- await client.xAdd(DAILY_REPORT_SSE_STREAM_KEY, "*", { data: JSON.stringify(message) }, { TRIM: { strategy: "MAXLEN", strategyModifier: "~", threshold: DAILY_REPORT_SSE_STREAM_MAXLEN } })
312
- const publishDurationMs = Date.now() - publishStartMs
313
- if (publishDurationMs > 1000) {
314
- logger.warn(`[SSE] Slow publish (${callerName}): ${publishDurationMs}ms`)
315
- }
316
- } catch (e) {
317
- logger.error(`[SSE] Redis publish failed (${callerName}):`, e)
318
- }
319
- }
320
-
321
- /**
322
- * Masks a raw audit-actor value (created_by / updated_by) before client exposure.
323
- * 監査列 (created_by / updated_by) をクライアント公開前にマスクする処理。
324
- *
325
- * Internal 由来の値は String(userId) の生内部 ID のため、数値なら encodeUserId で難読化する。
326
- * 非数値 (外部ソース由来の氏名やシステム文字列) はそのまま返す。null / 空文字は null。
327
- * userId フィールドと同じ難読化を監査列にも適用し、内部 ID の横流し漏洩を防ぐ。
328
- */
329
- const maskAuditActor = (value: string | null | undefined): string | null => {
330
- if (value == null || value === "") return null
331
- return /^\d+$/.test(value) ? encodeUserId(Number(value)) : value
332
- }
333
-
334
- type RawJsonComment = {
335
- id: number
336
- content: string
337
- createdAt: string
338
- userId: number
339
- userName: string
340
- }
341
-
342
- type RawJsonLabel = {
343
- id: number
344
- name: string
345
- color: string | null
346
- }
347
-
348
- /** 詳細クエリ 1 行 (外部ソース列は `ext_<sourceType>` キーで同居)。 */
349
- type HubQueryRow = {
350
- hub: DailyReportHubRow
351
- internal: DailyReportInternalRow | null
352
- isRead?: boolean | null
353
- isStarred?: boolean | null
354
- creatorName?: string | null
355
- } & Record<string, unknown>
356
-
357
- type HubRecord = HubQueryRow & {
358
- labels?: RawJsonLabel[]
359
- /** パッケージ自身のコメント表 (投稿コメント) の行。detail の postedComments になる。 */
360
- postedCommentRows?: RawJsonComment[]
361
- /**
362
- * 添付ファイル一覧。**任意にしない。**
363
- * `?` を付けると `mapHubRecord` 側で `?? []` が必要になり、注入を忘れた読み取り経路が
364
- * 黙って空配列を返す。必須にしておけば注入漏れは全呼び出し箇所でコンパイルエラーになる。
365
- */
366
- attachments: DailyReportAttachmentSummary[]
367
- }
368
-
369
- /** 外部ソースアダプタの select 追加フィールドを構築する。 */
370
- const externalSelections = (): Record<string, unknown> => {
371
- const out: Record<string, unknown> = {}
372
- for (const adapter of externalSources) {
373
- out[`ext_${adapter.sourceType}`] = cols(adapter.table)
374
- }
375
- return out
376
- }
377
-
378
- /** 外部ソースアダプタの LEFT JOIN を select チェーンへ適用する。 */
379
- const applyExternalJoins = <T>(chain: SelectChain<T>): SelectChain<T> => {
380
- let c = chain
381
- for (const adapter of externalSources) {
382
- c = c.leftJoin(adapter.table, and(eq(DailyReportHub.sourceType, adapter.sourceType), eq(DailyReportHub.sourceIdNum, adapter.idColumn)))
383
- }
384
- return c
385
- }
386
-
387
- /**
388
- * Converts a Hub record into a DailyReportDetail structure, applying the viewer's comment visibility.
389
- * Hub レコードを DailyReportDetail に変換し、閲覧者のコメント可視性を適用する処理。
390
- *
391
- * ❗ **コメント 2 面の唯一の生成点**。詳細取得 / 営業日一覧 / 管理経路 / publish 応答の全てが
392
- * ここを通るため、コメント次元の射影はここ 1 箇所に置く。SQL の `WHERE` 側で絞るのは不可 —
393
- * コメント表を引くクエリは `postedComments` にしか効かず、レガシー `comments` は外部ソース
394
- * アダプタの出力なのでクエリを通らない (半分しか塞げない)。
395
- *
396
- * ❗ 秘匿は**必ず空配列**で行う。キーごと省くと SSE 側は `dailyReportDetailSchema` の必須キー
397
- * 欠落で `safeParse` に失敗し、メッセージ全体が fail-close で落ちる (更新が永久に届かない)。
398
- * HTTP 側もクライアントの `DailyReportCache.merge` が `postedComments` を無ガードで参照する。
399
- *
400
- * @param scope Viewer scope carrying the comment dimension. コメント次元を運ぶ閲覧範囲。
401
- * @param row Hub record to convert. 変換対象の Hub レコード。
402
- * @param currentUserId Viewer's raw internal user id, if known. 判明していれば閲覧者の生内部ユーザー ID。
403
- * @returns The detail structure. 変換した詳細構造。
404
- */
405
- const mapHubRecord = (scope: DailyReportViewerScope, row: HubRecord, currentUserId?: number): DailyReportDetail => {
406
- const { hub, internal } = row
407
-
408
- let content = hub.summary
409
- let interviewers: DailyReportInterviewer[] = []
410
- let category: string | null = null
411
- let creationCategory: string | null = null
412
- let visitTimeFrom: string | null = null
413
- let visitTimeTo: string | null = null
414
- let customerName: string | null = null
415
- let employeeName: string | null = null
416
- let externalComments: DailyReportExternalComment[] = []
417
-
418
- // 外部ソースアダプタ優先 → Internal の順で表示フィールドを解決する
419
- const adapter = externalSources.find((a) => a.sourceType === hub.sourceType)
420
- const externalRow = adapter ? (row[`ext_${adapter.sourceType}`] as Record<string, unknown> | null | undefined) : undefined
421
- if (adapter && externalRow) {
422
- const fields = adapter.mapRow(externalRow)
423
- if (fields.content !== undefined) content = fields.content
424
- if (fields.employeeName !== undefined) employeeName = fields.employeeName
425
- if (fields.category !== undefined) category = fields.category
426
- if (fields.creationCategory !== undefined) creationCategory = fields.creationCategory
427
- if (fields.visitTimeFrom !== undefined) visitTimeFrom = fields.visitTimeFrom
428
- if (fields.visitTimeTo !== undefined) visitTimeTo = fields.visitTimeTo
429
- if (fields.customerName !== undefined) customerName = fields.customerName
430
- if (fields.interviewers !== undefined) interviewers = fields.interviewers
431
- if (fields.externalComments !== undefined) externalComments = fields.externalComments
432
- } else if (hub.sourceType === "Internal" && internal) {
433
- content = internal.body
434
- // Internal specific mappings if any
435
- }
436
-
437
- const labelsRaw = (row.labels ?? []).map((l) => ({
438
- id: l.id,
439
- name: l.name,
440
- color: l.color,
441
- }))
442
- const postedCommentsRaw = row.postedCommentRows ?? []
443
-
444
- // コメント次元がこの区分を拒否するなら、コメント 2 面を落として返す。
445
- // ❗ 自分のコメントだけは常に残す (要件 5)。隠すと「UI に見えないのに削除だけできる」
446
- // 非対称が生まれ、権限剥奪の瞬間に既存の自分のコメントが恒久的に削除不能になる。
447
- // 外部由来 `externalComments` はユーザー ID を持たない構造なので区分単位の二値にしかできない
448
- const commentsVisible = isSourceTypeVisible(scope.commentable, hub.sourceType)
449
-
450
- // 作成区分がアプリ設定の下書き候補に合致する場合、ラベル一覧に下書きラベルが含まれるよう補完
451
- const isCreationCategoryDraft = creationCategory ? draftLabelNames.includes(creationCategory) : false
452
- // 同期関数なので DB へは問い合わせず、既に解決済みのキャッシュがある場合だけ補完する
453
- const cachedPrimaryDraftLabelId = cachedDraftLabelIds?.[0] ?? null
454
- if (isCreationCategoryDraft && cachedPrimaryDraftLabelId && !labelsRaw.some((l) => l.id === cachedPrimaryDraftLabelId)) {
455
- labelsRaw.push({
456
- id: cachedPrimaryDraftLabelId,
457
- name: draftLabelName,
458
- color: null,
459
- })
460
- }
461
-
462
- return {
463
- reportHubId: hub.id,
464
- date: formatDateValue(hub.businessDate, "YYYY-MM-DD"),
465
- author: row.creatorName ?? maskAuditActor(hub.createdBy) ?? "",
466
- userId: hub.userId ? encodeUserId(hub.userId) : "",
467
- sourceType: hub.sourceType ?? "Internal",
468
- createdAt: formatDateValue(hub.createdAt, "YYYY-MM-DD HH:mm:ss"),
469
- updatedAt: formatDateValue(hub.updatedAt, "YYYY-MM-DD HH:mm:ss"),
470
- updatedBy: maskAuditActor(hub.updatedBy),
471
- employeeName: employeeName ?? row.creatorName ?? maskAuditActor(hub.createdBy),
472
- category,
473
- creationCategory,
474
- visitTimeFrom,
475
- visitTimeTo,
476
- customerName,
477
- interviewers,
478
- subject: hub.title,
479
- content,
480
- externalComments: commentsVisible ? externalComments : [],
481
- isRead: row.isRead ?? false,
482
- isStarred: row.isStarred ?? false,
483
- labels: labelsRaw,
484
- postedComments: postedCommentsRaw
485
- .filter((c) => commentsVisible || (currentUserId !== undefined && c.userId === currentUserId))
486
- .map((c) => ({
487
- ...c,
488
- userId: encodeUserId(c.userId),
489
- isMine: currentUserId ? c.userId === currentUserId : false,
490
- })),
491
- // `?? []` を書かない。HubRecord 側で必須にしてあるため、注入漏れはここではなく
492
- // 呼び出し箇所でコンパイルエラーになる (それが検知したい事象)。
493
- attachments: row.attachments,
494
- }
495
- }
496
-
497
- /**
498
- * Fetches daily report IDs alongside normalized business dates.
499
- * 日報 ID と正規化済み営業日を取得する内部処理。
500
- *
501
- * ❗ 可視性述語は他の 3 経路 (営業日一覧 / 詳細取得 / 添付取得) と**意図的に 1 つ緩い**。
502
- * ここだけ `isNull(DailyReportHub.userId)` を追加で許しており、投稿者を持たない
503
- * 取込元の日報を一覧から消さないための緩和である。返すのは ID・営業日・ソース種別だけで
504
- * 本文も添付も含まないため、緩めても内容は漏れない。
505
- * この差分は無自覚なものではなく、`service.spec.ts` が明示的に表明して固定している。
506
- * 他の 3 経路をこの形に合わせてはならない (合わせると内容が漏れる)。
507
- *
508
- * 区分 (sourceType) の可視性は緩和の対象外であり、他経路と完全に同一の条件を適用する。
509
- *
510
- * @param userId Internal numeric user id. 内部数値ユーザー ID。
511
- * @param scope Viewer scope carrying the visible source types. 可視 sourceType を運ぶ閲覧範囲。
512
- * @returns Stream-ordered items. ストリーム順のアイテム一覧。
513
- */
514
- const fetchDailyReportIdsByUserId = async (scope: DailyReportViewerScope, userId: number): Promise<DailyReportItem[]> => {
515
- const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
516
- const draftLabelJoinIds = await getDraftLabelJoinIds()
517
-
518
- const result = await db
519
- .select<{ reportHubId: number; businessDate: Date | string | null; sourceType: string }>({
520
- reportHubId: DailyReportHub.id,
521
- businessDate: DailyReportHub.businessDate,
522
- sourceType: DailyReportHub.sourceType,
523
- })
524
- .from(DailyReportHub)
525
- .leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
526
- .where(and(isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DailyReportHub.userId), isNull(DraftLabelRelation.hubId)), visibleSourceTypeCondition(scope.visible)))
527
- .orderBy(desc(DailyReportHub.businessDate), desc(DailyReportHub.id))
528
-
529
- return result.map((item) => ({
530
- ...item,
531
- businessDate: formatDateValue(item.businessDate, "YYYY-MM-DD"),
532
- }))
533
- }
534
-
535
- /**
536
- * Fetches only the first page of report ids for time-to-first-render (uncached).
537
- * 最初の描画までの時間専用に、ID 一覧の先頭ページだけを取得する内部処理 (キャッシュ無し)。
538
- *
539
- * `ids-stream` の新規接続でキャッシュミスした場合、全量クエリの確定を待たずに
540
- * この軽量クエリ (TOP n・インデックス順で n 行走査した時点で打ち切り) の結果を
541
- * 最初のチャンクとして送出する。可視性述語と並び順は全量クエリと完全同一。
542
- * `.top(n)` は `.select()` の直後でなければならない (drizzle-mssql の構造的制約)。
543
- *
544
- * @param externalId External user id. 外部ユーザー ID。
545
- * @param limit Page size. ページサイズ。
546
- * @param scope Viewer scope carrying the visible source types. 可視 sourceType を運ぶ閲覧範囲。
547
- * @returns First-page items in stream order. ストリーム順の先頭ページ。
548
- */
549
- const getDailyReportIdsFirstPageByExternalId = async (scope: DailyReportViewerScope, externalId: string, limit: number): Promise<DailyReportItem[]> => {
550
- const userId = await getUserIdByExternalId(externalId)
551
- if (!userId) {
552
- return []
553
- }
554
- // 全拒否なら DB を引かずに空を返す (0 件 = fail-close)
555
- if (deniesEverySourceType(scope.visible)) {
556
- return []
557
- }
558
- const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
559
- const draftLabelJoinIds = await getDraftLabelJoinIds()
560
-
561
- const result = await db
562
- .select<{ reportHubId: number; businessDate: Date | string | null; sourceType: string }>({
563
- reportHubId: DailyReportHub.id,
564
- businessDate: DailyReportHub.businessDate,
565
- sourceType: DailyReportHub.sourceType,
566
- })
567
- .top(limit)
568
- .from(DailyReportHub)
569
- .leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
570
- .where(and(isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DailyReportHub.userId), isNull(DraftLabelRelation.hubId)), visibleSourceTypeCondition(scope.visible)))
571
- .orderBy(desc(DailyReportHub.businessDate), desc(DailyReportHub.id))
572
-
573
- return result.map((item) => ({
574
- ...item,
575
- businessDate: formatDateValue(item.businessDate, "YYYY-MM-DD"),
576
- }))
577
- }
578
-
579
- /**
580
- * Fetches daily report details for the provided business date.
581
- * 指定した営業日の日報詳細を取得する内部処理。
582
- */
583
- const fetchDailyReportsByBusinessDate = async (scope: DailyReportViewerScope, normalizedBusinessDate: string): Promise<DailyReportDetail[]> => {
584
- const rows = await applyExternalJoins(
585
- db
586
- .select<HubQueryRow>({
587
- hub: cols(DailyReportHub),
588
- internal: cols(DailyReportInternal),
589
- creatorName: users.displayName,
590
- ...externalSelections(),
591
- })
592
- .from(DailyReportHub),
593
- )
594
- .leftJoin(DailyReportInternal, eq(DailyReportHub.id, DailyReportInternal.hubId))
595
- .leftJoin(users, eq(DailyReportHub.userId, users.id))
596
- .where(and(eq(DailyReportHub.businessDate, sql`${normalizedBusinessDate}`), isNull(DailyReportHub.deletedAt), visibleSourceTypeCondition(scope.visible)))
597
- .orderBy(desc(DailyReportHub.id))
598
-
599
- const attachmentsMap = await getAttachmentsByHubIds(rows.map((r) => r.hub.id))
600
- return rows.map((row) => mapHubRecord(scope, { ...row, attachments: attachmentsMap.get(row.hub.id) ?? [] }))
601
- }
602
-
603
- /**
604
- * Builds a deterministic cache key for business-date caches.
605
- * 営業日キャッシュ用の一意キーを生成する処理。
606
- */
607
- const buildBusinessDateCacheKey = (normalizedBusinessDate: string): string => {
608
- return `${DAILY_REPORT_BUSINESS_DATE_CACHE_PREFIX}${normalizedBusinessDate}`
609
- }
610
-
611
- /**
612
- * Resolves the internal user ID from an external ID (with process-local caching).
613
- * 外部 ID から内部ユーザー ID を解決する (プロセス内キャッシュ付き)。
614
- */
615
- const userIdCache = new Map<string, number>()
616
-
617
- const getUserIdByExternalId = async (externalId: string): Promise<number | null> => {
618
- if (!config.disableUserIdCache && userIdCache.has(externalId)) {
619
- return userIdCache.get(externalId) ?? null
620
- }
621
-
622
- const userId = await resolveUserId(externalId)
623
- if (userId !== null && !config.disableUserIdCache) {
624
- userIdCache.set(externalId, userId)
625
- }
626
- return userId
627
- }
628
-
629
- /**
630
- * Retrieves daily report IDs and their normalized business dates.
631
- * 日報 ID と正規化済み営業日を取得する処理。
632
- *
633
- * @param externalId External user id. 外部ユーザー ID。
634
- * @param options Cache query options. キャッシュ問い合わせオプション。
635
- * @param scope Viewer scope carrying the visible source types. 可視 sourceType を運ぶ閲覧範囲。
636
- * @returns Stream-ordered items. ストリーム順のアイテム一覧。
637
- */
638
- const getDailyReportIdsByExternalId = async (scope: DailyReportViewerScope, externalId: string, { forceRefresh = false, snapshot = false, ttlMsOverride, onFetchStart }: SqlResultCacheQueryOptions & { onFetchStart?: () => void } = {}): Promise<DailyReportItem[]> => {
639
- const userId = await getUserIdByExternalId(externalId)
640
- if (!userId) {
641
- return []
642
- }
643
- // 全拒否なら DB を引かずに空を返す (0 件 = fail-close)
644
- if (deniesEverySourceType(scope.visible)) {
645
- return []
646
- }
647
-
648
- // 可視集合ごとに別バケットへ隔離する (権限変更後に旧結果を配信しない)
649
- const cacheKey = idsCacheKey(userId, computeViewerScopeDigest(scope))
650
- return sqlResultCache.getOrFetch<DailyReportItem>({
651
- cacheKey,
652
- fetcher: async () => {
653
- const res = await fetchDailyReportIdsByUserId(scope, userId)
654
- // ❗ 並んでいない配列はカーソル再開の前提を破る (findIdsStreamResumeIndex は
655
- // 比較器で「最初に後ろへ来る位置」を探すため、並びが崩れていると誤った位置から
656
- // 再開する)。原因は SQL の ORDER BY と compareIdsStreamOrder の乖離、または
657
- // RCSI の無い配備でのスキャン中の行移動 (実運用 DB は dev / 本番とも
658
- // READ_COMMITTED_SNAPSHOT = ON を実機確認済みで、その配備では後者は起きない —
659
- // 2026-08-15 検証)。fetcher 内で治癒するのは、
660
- // ①検査が fetch 1 回につき 1 度で済み ②バケットとスナップショットトークンが
661
- // 治癒済み配列を見る、の両方を成立させるため。
662
- // ❗ 並べ直しても欠落・重複は直らない — 直るのはカーソル再開の二次被害だけ
663
- if (!isIdsStreamOrdered(res)) {
664
- logger.warn("[DailyReportService] ids array is not in stream order; re-sorting (check SQL ORDER BY vs compareIdsStreamOrder, or concurrent row movement during the scan)")
665
- return [...res].sort(compareIdsStreamOrder)
666
- }
667
- return res
668
- },
669
- // 本呼び出しが実際に全量クエリを起動した瞬間だけ合図する (バケットヒット・相乗り・
670
- // refresh-ahead では呼ばれない)。ids ストリームはこれを高速先頭ページ起動の
671
- // 因果的な前提条件に使う
672
- onFetchStart,
673
- forceRefresh,
674
- snapshot,
675
- ttlMsOverride: ttlMsOverride ?? DAILY_REPORT_IDS_TTL_MS,
676
- epochKey: DAILY_REPORT_IDS_EPOCH_KEY,
677
- // TTL の半分を過ぎたヒットは、返却後に裏で 1 本だけ再フェッチして差し替える。
678
- // 90 秒に 1 回以上アクセスがある限り、対話リクエストはコールド (全量再クエリ) を踏まない
679
- refreshAheadRatio: 0.5,
680
- }) as Promise<DailyReportItem[]>
681
- }
682
-
683
- /**
684
- * Retrieves daily report details for a business date (⚠️ NOT per-user filtered).
685
- * 指定した営業日に紐づく日報詳細を取得する処理 (⚠️ ユーザー別フィルタなし)。
686
- *
687
- * ⚠️ SECURITY: この関数は下書きラベルによる可視性フィルタ (他ユーザーの下書きを隠す) と
688
- * ユーザー別の既読/スター状態を **適用しない**。取得結果は全ユーザーの下書きを含み得るため、
689
- * HTTP レスポンス / SSR loader / SSE へ **直接返してはならない**。ユーザー向け配信には
690
- * 必ず {@link getDailyReportsByBusinessDateByExternalId} を使うこと。
691
- * (本関数は管理・バッチ・テスト用途に限定する。)
692
- */
693
- const getDailyReportsByBusinessDate = (scope: DailyReportViewerScope, businessDate: string, { forceRefresh = false, snapshot = false, ttlMsOverride }: SqlResultCacheQueryOptions = {}): Promise<DailyReportDetail[]> => {
694
- const normalizedBusinessDate = normalizeBusinessDateKey(businessDate)
695
- if (!normalizedBusinessDate) {
696
- return Promise.resolve([])
697
- }
698
- // 全拒否なら DB を引かずに空を返す (0 件 = fail-close)
699
- if (deniesEverySourceType(scope.visible)) {
700
- return Promise.resolve([])
701
- }
702
-
703
- return sqlResultCache.getOrFetch<DailyReportDetail>({
704
- // 可視集合ごとに別バケットへ隔離する (管理経路でも権限混在を起こさない)
705
- cacheKey: `${buildBusinessDateCacheKey(normalizedBusinessDate)}:st:${computeViewerScopeDigest(scope)}`,
706
- fetcher: () => fetchDailyReportsByBusinessDate(scope, normalizedBusinessDate),
707
- forceRefresh,
708
- snapshot,
709
- ttlMsOverride: ttlMsOverride ?? DAILY_REPORT_BUSINESS_DATE_TTL_MS,
710
- }) as Promise<DailyReportDetail[]>
711
- }
712
-
713
- /**
714
- * Retrieves every label id that counts as a draft.
715
- * 下書きとみなすラベル ID を**すべて**取得する処理 (キャッシュ付き)。
716
- *
717
- * ❗ 可視性述語は必ずこちら (複数形) を使うこと。`draftLabelNames` には複数のラベル名を
718
- * 注入できる (取込元ごとに下書きの呼び名が違うため) のに、1 個の ID だけで結合すると
719
- * **2 つ目以降の下書きラベルが付いた日報が誰にでも可視になる**。
720
- * 名前が 1 つも解決できない場合は空配列を返し、呼び出し側は結合対象を
721
- * 「存在しない ID」にして「下書きは存在しない」と解釈する。
722
- *
723
- * @returns Draft label ids, ascending. 昇順の下書きラベル ID 群。
724
- */
725
- let cachedDraftLabelIds: number[] | null = null
726
- const getDraftLabelIds = async (): Promise<number[]> => {
727
- if (cachedDraftLabelIds !== null) return cachedDraftLabelIds
728
- const rows = await db.select<{ id: number }>({ id: DailyReportLabel.id }).from(DailyReportLabel).where(inArray(DailyReportLabel.name, draftLabelNames)).orderBy(DailyReportLabel.id)
729
- cachedDraftLabelIds = rows.map((row) => row.id)
730
- return cachedDraftLabelIds
731
- }
732
-
733
- /**
734
- * Join targets for the draft-label relation, safe when no draft label exists.
735
- * 下書きラベル関連の結合対象。下書きラベルが 1 件も無い場合でも安全に働く値。
736
- *
737
- * `inArray` に空配列を渡すと方言によっては構文エラーや常に真になりうるため、
738
- * 実在しない ID (`-1`) を 1 個入れて「どの行にも一致しない」ことを明示する。
739
- *
740
- * @returns Ids to join on. 結合に使う ID 群。
741
- */
742
- const getDraftLabelJoinIds = async (): Promise<number[]> => {
743
- const ids = await getDraftLabelIds()
744
- return ids.length > 0 ? ids : [-1]
745
- }
746
-
747
- /**
748
- * Retrieves the canonical draft label id used when creating a draft.
749
- * 下書きを作成するときに付与する代表の下書きラベル ID を取得する処理。
750
- *
751
- * 付与は 1 個に決めなければならないため `draftLabelNames` の先頭に対応する ID を返す。
752
- * **可視性判定には使わないこと** (`getDraftLabelIds` を使う)。
753
- *
754
- * @returns The canonical draft label id, or null when it does not exist. 代表の下書きラベル ID (存在しなければ null)。
755
- */
756
- const getDraftLabelId = async (): Promise<number | null> => {
757
- const ids = await getDraftLabelIds()
758
- if (ids.length === 0) return null
759
- // 先頭の名前に対応する ID を選ぶ。ID 順ではなく注入順を正とする (代表名は draftLabelName)
760
- const [primary] = await db.select<{ id: number }>({ id: DailyReportLabel.id }).top(1).from(DailyReportLabel).where(eq(DailyReportLabel.name, draftLabelName))
761
- return primary?.id ?? ids[0]
762
- }
763
-
764
- /**
765
- * Derives the tri-state verification status of an attachment's backing object.
766
- * 添付ファイル実体の検証状態 (三値) を導出する処理。
767
- *
768
- * 判定不能 (`unknown`) を `absent` へ丸めない。存在確認そのものが失敗した場合を
769
- * 偽の真偽で埋めると、一時的な権限・通信障害が「消失」として表示される。
770
- */
771
- const deriveAttachmentState = (checkedAt: Date | string | null, missingAt: Date | string | null): DailyReportAttachmentSummary["state"] => {
772
- if (missingAt != null) return "absent"
773
- if (checkedAt != null) return "present"
774
- return "unknown"
775
- }
776
-
777
- /**
778
- * Fetches attachments for multiple hubs in one query, keyed by hub id.
779
- * 複数 hub の添付ファイルを 1 クエリで取得し hub ID をキーにまとめる処理。
780
- *
781
- * 一覧描画は N 件の日報を同時に返すため、日報ごとに問い合わせると N+1 になる。
782
- * ラベル・コメントと同じ一括取得の流儀に揃える。
783
- *
784
- * @param hubIds Hub ids to fetch attachments for. 添付を取得する hub ID 群。
785
- * @returns Attachments grouped by hub id. hub ID ごとにまとめた添付一覧。
786
- */
787
- const getAttachmentsByHubIds = async (hubIds: number[]): Promise<Map<number, DailyReportAttachmentSummary[]>> => {
788
- const map = new Map<number, DailyReportAttachmentSummary[]>()
789
- const attachment = tables.attachment
790
- // codec は config 直参照。createDailyReportService の分割代入は 8 キーのみで、
791
- // 裸の識別子として書くと TS2304 になる (任意設定は config.x で読むのがこのファイルの様式)
792
- const codec = config.attachmentIdCodec
793
- if (hubIds.length === 0 || !attachment || !codec) {
794
- return map
795
- }
796
-
797
- const rows = await db
798
- .select<{ id: number; hubId: number; fileName: string; fileType: string | null; fileSize: number | null; createdAt: Date | string | null; objectCheckedAt: Date | string | null; objectMissingAt: Date | string | null }>({
799
- id: attachment.id,
800
- hubId: attachment.hubId,
801
- fileName: attachment.fileName,
802
- fileType: attachment.fileType,
803
- fileSize: attachment.fileSize,
804
- createdAt: attachment.createdAt,
805
- objectCheckedAt: attachment.objectCheckedAt,
806
- objectMissingAt: attachment.objectMissingAt,
807
- })
808
- .from(attachment)
809
- .where(inArray(attachment.hubId, hubIds))
810
- .orderBy(asc(attachment.createdAt), asc(attachment.id))
811
-
812
- for (const row of rows) {
813
- // 内部数値 ID はここで難読化トークンへ変換する。以降クライアントへ生 ID は出ない。
814
- // `uploaded_by` / `created_by` / `updated_by` / `file_path` は選択もしない
815
- const item: DailyReportAttachmentSummary = {
816
- id: codec.encode(Number(row.id)),
817
- fileName: String(row.fileName),
818
- fileType: row.fileType ? String(row.fileType) : null,
819
- fileSize: row.fileSize != null ? Number(row.fileSize) : null,
820
- createdAt: formatDateValue(row.createdAt, "YYYY-MM-DD HH:mm:ss"),
821
- state: deriveAttachmentState(row.objectCheckedAt, row.objectMissingAt),
822
- }
823
- const list = map.get(Number(row.hubId))
824
- if (list) {
825
- list.push(item)
826
- } else {
827
- map.set(Number(row.hubId), [item])
828
- }
829
- }
830
-
831
- return map
832
- }
833
-
834
- /**
835
- * Fetches one attachment together with its visibility check for a given user.
836
- * 指定ユーザーの可視性判定込みで添付ファイル 1 件を取得する処理。
837
- *
838
- * 認可は SQL 側で完結させる。アプリ層のフィルタリングに依存しない。
839
- * 認可されない / 存在しない / 親日報が論理削除済みのいずれでも `null` を返すため、
840
- * 呼び出し側は 403 と 404 を区別せず 404 とすること (存在オラクル回避)。
841
- *
842
- * 本関数は意図的に `SqlResultCache.getOrFetch` を通さない。これは読み取りキャッシュではなく
843
- * リクエストごとの**認可判定**であり、結果をキャッシュするとユーザーを跨いで再利用されうる。
844
- *
845
- * @param attachmentId Internal numeric attachment id. 内部数値の添付 ID。
846
- * @param userId Internal numeric user id of the viewer. 閲覧者の内部数値ユーザー ID。
847
- * @param scope Viewer scope carrying the visible source types. 可視 sourceType を運ぶ閲覧範囲。
848
- * @returns Attachment row when visible, otherwise null. 可視なら添付行、それ以外は null。
849
- */
850
- const getAttachmentForUser = async (scope: DailyReportViewerScope, attachmentId: number, userId: number): Promise<{ fileName: string; filePath: string; fileType: string | null; fileSize: number | null } | null> => {
851
- const attachment = tables.attachment
852
- if (!attachment) {
853
- return null
854
- }
855
- // 全拒否なら DB を引かずに null を返す (0 件 = fail-close)
856
- if (deniesEverySourceType(scope.visible)) {
857
- return null
858
- }
859
- const draftLabelJoinIds = await getDraftLabelJoinIds()
860
- const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
861
-
862
- // `.top(1)` は `.select()` の直後でなければならない。drizzle-mssql は top() を
863
- // db.select() が返す MsSqlSelectBuilder にしか定義しておらず、.from() 以降のクラスには
864
- // 存在しない。誤った順序は型 (構造型 SelectChain が全段階に top を宣言) でも
865
- // 単体テスト (モックが全段階で top を返す) でも検出されず、本番で TypeError になる。
866
- const rows = await db
867
- .select<{ fileName: string; filePath: string; fileType: string | null; fileSize: number | null }>({
868
- fileName: attachment.fileName,
869
- filePath: attachment.filePath,
870
- fileType: attachment.fileType,
871
- fileSize: attachment.fileSize,
872
- })
873
- .top(1)
874
- .from(attachment)
875
- .innerJoin(DailyReportHub, eq(DailyReportHub.id, attachment.hubId))
876
- .leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
877
- .where(and(eq(attachment.id, attachmentId), isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId)), visibleSourceTypeCondition(scope.visible)))
878
-
879
- return rows[0] ?? null
880
- }
881
-
882
- /**
883
- * Records that an attachment's backing object was confirmed missing.
884
- * 添付ファイルの実体が消失していたことを記録する処理。
885
- *
886
- * 実配信で `not_found` を得たときに呼ぶ。次回描画で三値が `absent` になる。
887
- * 記録の失敗は配信結果に影響させない (呼び出し側は await しない)。
888
- *
889
- * @param attachmentId Internal numeric attachment id. 内部数値の添付 ID。
890
- */
891
- const markAttachmentMissing = async (attachmentId: number): Promise<void> => {
892
- const attachment = tables.attachment
893
- if (!attachment) return
894
- const now = new Date()
895
- try {
896
- await db.update(attachment).set({ objectCheckedAt: now, objectMissingAt: now, updatedAt: now }).where(eq(attachment.id, attachmentId))
897
- } catch (error) {
898
- logger.error("markAttachmentMissing failed", error)
899
- }
900
- }
901
-
902
- /**
903
- * Records that an attachment's backing object was confirmed present.
904
- * 添付ファイルの実体が存在していたことを記録する処理。
905
- *
906
- * `markAttachmentMissing` と対にすること。消失記録が一方向だと、一時的な障害で
907
- * 一度 `absent` が立った添付は UI がリンクを描画しなくなり (押しても 404 なので当然)、
908
- * **利用者が再取得を発火させる手段そのものが消える**。実体が戻っても手動の
909
- * 一括検証を回すまで復帰しない。配信が 200 で成功した時点で解除するのが唯一の
910
- * 自動回復経路である。
911
- *
912
- * 記録の失敗は配信結果に影響させない (呼び出し側は await しない)。
913
- *
914
- * @param attachmentId Internal numeric attachment id. 内部数値の添付 ID。
915
- */
916
- const markAttachmentPresent = async (attachmentId: number): Promise<void> => {
917
- const attachment = tables.attachment
918
- if (!attachment) return
919
- const now = new Date()
920
- try {
921
- await db.update(attachment).set({ objectCheckedAt: now, objectMissingAt: null, updatedAt: now }).where(eq(attachment.id, attachmentId))
922
- } catch (error) {
923
- logger.error("markAttachmentPresent failed", error)
924
- }
925
- }
926
-
927
- /**
928
- * Retrieves daily report details for a business date with user-specific status and relations.
929
- * 指定した営業日の日報詳細を、ユーザー状態と関連データを含めて取得する。
930
- */
931
- const getDailyReportsByBusinessDateByExternalId = async (scope: DailyReportViewerScope, businessDate: string, externalId: string, { forceRefresh = false, snapshot = false, ttlMsOverride }: SqlResultCacheQueryOptions = {}): Promise<DailyReportDetail[]> => {
932
- const userId = await getUserIdByExternalId(externalId)
933
- if (!userId) {
934
- return []
935
- }
936
-
937
- const normalizedBusinessDate = normalizeBusinessDateKey(businessDate)
938
- if (!normalizedBusinessDate) {
939
- return []
940
- }
941
- // 全拒否なら DB を引かずに空を返す (0 件 = fail-close)
942
- if (deniesEverySourceType(scope.visible)) {
943
- return []
944
- }
945
-
946
- // 可視集合ごとに別バケットへ隔離する (権限変更後に旧結果を配信しない)
947
- const cacheKey = dateCacheKey(normalizedBusinessDate, userId, computeViewerScopeDigest(scope))
948
-
949
- return sqlResultCache.getOrFetch<DailyReportDetail>({
950
- cacheKey,
951
- fetcher: async () => {
952
- const draftLabelJoinIds = await getDraftLabelJoinIds()
953
- const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
954
-
955
- const rows = await applyExternalJoins(
956
- db
957
- .select<HubQueryRow>({
958
- hub: cols(DailyReportHub),
959
- internal: cols(DailyReportInternal),
960
- isRead: DailyReportUserStatus.isRead,
961
- isStarred: DailyReportUserStatus.isStarred,
962
- creatorName: users.displayName,
963
- ...externalSelections(),
964
- })
965
- .from(DailyReportHub),
966
- )
967
- .leftJoin(DailyReportInternal, eq(DailyReportHub.id, DailyReportInternal.hubId))
968
- .leftJoin(DailyReportUserStatus, and(eq(DailyReportHub.id, DailyReportUserStatus.hubId), eq(DailyReportUserStatus.userId, userId)))
969
- .leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
970
- .leftJoin(users, eq(DailyReportHub.userId, users.id))
971
- .where(and(eq(DailyReportHub.businessDate, sql`${normalizedBusinessDate}`), isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId)), visibleSourceTypeCondition(scope.visible)))
972
- .orderBy(desc(DailyReportHub.id))
973
-
974
- if (rows.length === 0) {
975
- return []
976
- }
977
-
978
- const hubIds = rows.map((r) => r.hub.id)
979
-
980
- // Drizzle ORM batch queries to fetch labels and comments
981
- const allLabels = await db
982
- .select<{ hubId: number; id: number; name: string; color: string | null }>({
983
- hubId: DailyReportHub_Label.hubId,
984
- id: DailyReportLabel.id,
985
- name: DailyReportLabel.name,
986
- color: DailyReportLabel.color,
987
- })
988
- .from(DailyReportHub_Label)
989
- .innerJoin(DailyReportLabel, eq(DailyReportHub_Label.labelId, DailyReportLabel.id))
990
- .where(inArray(DailyReportHub_Label.hubId, hubIds))
991
-
992
- const allComments = await db
993
- .select<{ hubId: number; id: number; body: string; createdAt: Date | string; userId: number; userName: string | null }>({
994
- hubId: DailyReportCommentModel.hubId,
995
- id: DailyReportCommentModel.id,
996
- body: DailyReportCommentModel.body,
997
- createdAt: DailyReportCommentModel.createdAt,
998
- userId: DailyReportCommentModel.userId,
999
- userName: users.displayName,
1000
- })
1001
- .from(DailyReportCommentModel)
1002
- .leftJoin(users, eq(DailyReportCommentModel.userId, users.id))
1003
- .where(inArray(DailyReportCommentModel.hubId, hubIds))
1004
- .orderBy(asc(DailyReportCommentModel.createdAt))
1005
-
1006
- // 添付も同じ流儀で一括取得する (日報ごとに引くと N+1 になる)
1007
- const attachmentsMap = await getAttachmentsByHubIds(hubIds)
1008
-
1009
- const labelsMap = new Map<number, RawJsonLabel[]>()
1010
- const commentsMap = new Map<number, RawJsonComment[]>()
1011
-
1012
- for (const label of allLabels) {
1013
- let list = labelsMap.get(label.hubId)
1014
- if (!list) {
1015
- list = []
1016
- labelsMap.set(label.hubId, list)
1017
- }
1018
- list.push({
1019
- id: label.id,
1020
- name: label.name,
1021
- color: label.color,
1022
- })
1023
- }
1024
-
1025
- for (const comment of allComments) {
1026
- let list = commentsMap.get(comment.hubId)
1027
- if (!list) {
1028
- list = []
1029
- commentsMap.set(comment.hubId, list)
1030
- }
1031
- list.push({
1032
- id: comment.id,
1033
- content: comment.body,
1034
- createdAt: formatDateValue(comment.createdAt, "YYYY-MM-DD HH:mm:ss") || "",
1035
- userId: comment.userId,
1036
- userName: comment.userName || "",
1037
- })
1038
- }
1039
-
1040
- return rows.map((row) => {
1041
- const hubId = row.hub.id
1042
- return mapHubRecord(
1043
- scope,
1044
- {
1045
- ...row,
1046
- attachments: attachmentsMap.get(hubId) ?? [],
1047
- labels: labelsMap.get(hubId) || [],
1048
- postedCommentRows: commentsMap.get(hubId) || [],
1049
- },
1050
- userId,
1051
- )
1052
- })
1053
- },
1054
- forceRefresh,
1055
- snapshot,
1056
- ttlMsOverride: ttlMsOverride ?? DAILY_REPORT_BUSINESS_DATE_TTL_MS,
1057
- epochKey: `${DAILY_REPORT_DATE_EPOCH_PREFIX}${normalizedBusinessDate}`,
1058
- }) as Promise<DailyReportDetail[]>
1059
- }
1060
-
1061
- /**
1062
- * Retrieves a single daily report detail by ID with user-specific status and relations.
1063
- * 指定した日報詳細を、ユーザー状態と関連データを含めて取得する。
1064
- *
1065
- * 可視性の述語は営業日一覧 (`getDailyReportsByBusinessDateByExternalId`) および
1066
- * `getAttachmentForUser` と同一に保つこと。3 経路のどれか 1 つだけ緩いと、
1067
- * 緩い経路がメタデータ (件名・本文・添付のファイル名やトークン) を渡してしまい、
1068
- * 「SQL 述語が唯一の認可境界」という不変条件が崩れる。
1069
- * 4 番目の経路である `fetchDailyReportIdsByUserId` だけは意図的に 1 つ緩い
1070
- * (返すのが ID・営業日・ソース種別のみのため)。詳細は同関数の docstring を参照。
1071
- *
1072
- * @param reportHubId Hub id. 対象 hub の ID。
1073
- * @param userId Internal numeric user id of the viewer. 閲覧者の内部数値ユーザー ID。
1074
- * @param options Cache query options. キャッシュ問い合わせオプション。
1075
- * @param scope Viewer scope carrying the visible source types. 可視 sourceType を運ぶ閲覧範囲。
1076
- * @returns Detail when visible, otherwise null. 可視なら詳細、そうでなければ null。
1077
- */
1078
- const getDailyReportDetailById = async (scope: DailyReportViewerScope, reportHubId: number, userId: number, { forceRefresh = false, snapshot = false, ttlMsOverride }: SqlResultCacheQueryOptions = {}): Promise<DailyReportDetail | null> => {
1079
- // 全拒否なら DB を引かずに null を返す (0 件 = fail-close)
1080
- if (deniesEverySourceType(scope.visible)) {
1081
- return null
1082
- }
1083
-
1084
- // 可視集合ごとに別バケットへ隔離する (権限変更後に旧結果を配信しない)
1085
- const cacheKey = detailCacheKey(reportHubId, userId, computeViewerScopeDigest(scope))
1086
-
1087
- const results = (await sqlResultCache.getOrFetch<DailyReportDetail>({
1088
- cacheKey,
1089
- fetcher: async () => {
1090
- const draftLabelJoinIds = await getDraftLabelJoinIds()
1091
- const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
1092
-
1093
- const rows = await applyExternalJoins(
1094
- db
1095
- .select<HubQueryRow>({
1096
- hub: cols(DailyReportHub),
1097
- internal: cols(DailyReportInternal),
1098
- isRead: DailyReportUserStatus.isRead,
1099
- isStarred: DailyReportUserStatus.isStarred,
1100
- creatorName: users.displayName,
1101
- ...externalSelections(),
1102
- })
1103
- .from(DailyReportHub),
1104
- )
1105
- .leftJoin(DailyReportInternal, eq(DailyReportHub.id, DailyReportInternal.hubId))
1106
- .leftJoin(DailyReportUserStatus, and(eq(DailyReportHub.id, DailyReportUserStatus.hubId), eq(DailyReportUserStatus.userId, userId)))
1107
- .leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
1108
- .leftJoin(users, eq(DailyReportHub.userId, users.id))
1109
- .where(and(eq(DailyReportHub.id, reportHubId), isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId)), visibleSourceTypeCondition(scope.visible)))
1110
-
1111
- if (rows.length === 0) {
1112
- return []
1113
- }
1114
-
1115
- const hubId = rows[0].hub.id
1116
-
1117
- // Fetch labels and comments via Drizzle ORM
1118
- const labels = await db
1119
- .select<{ id: number; name: string; color: string | null }>({
1120
- id: DailyReportLabel.id,
1121
- name: DailyReportLabel.name,
1122
- color: DailyReportLabel.color,
1123
- })
1124
- .from(DailyReportHub_Label)
1125
- .innerJoin(DailyReportLabel, eq(DailyReportHub_Label.labelId, DailyReportLabel.id))
1126
- .where(eq(DailyReportHub_Label.hubId, hubId))
1127
-
1128
- const comments = await db
1129
- .select<{ id: number; body: string; createdAt: Date | string; userId: number; userName: string | null }>({
1130
- id: DailyReportCommentModel.id,
1131
- body: DailyReportCommentModel.body,
1132
- createdAt: DailyReportCommentModel.createdAt,
1133
- userId: DailyReportCommentModel.userId,
1134
- userName: users.displayName,
1135
- })
1136
- .from(DailyReportCommentModel)
1137
- .leftJoin(users, eq(DailyReportCommentModel.userId, users.id))
1138
- .where(eq(DailyReportCommentModel.hubId, hubId))
1139
- .orderBy(asc(DailyReportCommentModel.createdAt))
1140
-
1141
- const attachmentsMap = await getAttachmentsByHubIds([hubId])
1142
-
1143
- const labelsMapped = labels.map((l) => ({
1144
- id: l.id,
1145
- name: l.name,
1146
- color: l.color,
1147
- }))
1148
-
1149
- const commentsMapped = comments.map((c) => ({
1150
- id: c.id,
1151
- content: c.body,
1152
- createdAt: formatDateValue(c.createdAt, "YYYY-MM-DD HH:mm:ss") || "",
1153
- userId: c.userId,
1154
- userName: c.userName || "",
1155
- }))
1156
-
1157
- return [
1158
- mapHubRecord(
1159
- scope,
1160
- {
1161
- ...rows[0],
1162
- attachments: attachmentsMap.get(hubId) ?? [],
1163
- labels: labelsMapped,
1164
- postedCommentRows: commentsMapped,
1165
- },
1166
- userId,
1167
- ),
1168
- ]
1169
- },
1170
- forceRefresh,
1171
- snapshot,
1172
- ttlMsOverride: ttlMsOverride ?? DAILY_REPORT_BUSINESS_DATE_TTL_MS,
1173
- epochKey: `${DAILY_REPORT_DETAIL_EPOCH_PREFIX}${reportHubId}`,
1174
- })) as DailyReportDetail[]
1175
-
1176
- return results[0] ?? null
1177
- }
1178
-
1179
- /**
1180
- * Retrieves a single daily report detail by ID with user-specific status and relations.
1181
- * 指定した日報詳細を、ユーザー状態と関連データを含めて取得する。
1182
- *
1183
- * @param reportHubId Hub id. 対象 hub の ID。
1184
- * @param externalId External user id. 外部ユーザー ID。
1185
- * @param options Cache query options. キャッシュ問い合わせオプション。
1186
- * @param scope Viewer scope carrying the visible source types. 可視 sourceType を運ぶ閲覧範囲。
1187
- * @returns Detail when visible, otherwise null. 可視なら詳細、そうでなければ null。
1188
- */
1189
- const getDailyReportDetailByIdByExternalId = async (scope: DailyReportViewerScope, reportHubId: number, externalId: string, options: SqlResultCacheQueryOptions = {}): Promise<DailyReportDetail | null> => {
1190
- const userId = await getUserIdByExternalId(externalId)
1191
- if (!userId) {
1192
- return null
1193
- }
1194
- // ❗ 受け取った可視集合はそのまま透過する。ここで null へ潰すと外部 ID 経由の詳細取得だけ
1195
- // フィルタが外れる (内部の create/update/publish が明示 null を渡すのとは別物)
1196
- return getDailyReportDetailById(scope, reportHubId, userId, options)
1197
- }
1198
-
1199
- /**
1200
- * Loads the gate columns of a hub for a mutation, or null when it does not exist.
1201
- * ミューテーション用に hub のゲート列を取得する処理 (存在しなければ null)。
1202
- *
1203
- * @param reportHubId Hub id. 対象 hub の ID。
1204
- * @returns Source type and deletion marker, or null. ソース種別と論理削除印 (無ければ null)。
1205
- */
1206
- const findHubGateColumns = async (reportHubId: number, userId: number): Promise<{ sourceType: string; deletedAt: Date | string | null } | null> => {
1207
- const draftLabelJoinIds = await getDraftLabelJoinIds()
1208
- const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
1209
-
1210
- // ❗ 可視性の述語は読み取り経路と同一に保つ。下書き条項を落とすと、閲覧できないはずの
1211
- // 他人の下書きに対して既読 / スター / コメントを付けられる (書き込み可能な存在オラクル)
1212
- const [hub] = await db
1213
- .select<{ sourceType: string; deletedAt: Date | string | null }>({ sourceType: DailyReportHub.sourceType, deletedAt: DailyReportHub.deletedAt })
1214
- .top(1)
1215
- .from(DailyReportHub)
1216
- .leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
1217
- .where(and(eq(DailyReportHub.id, reportHubId), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId))))
1218
- return hub ?? null
1219
- }
1220
-
1221
- /**
1222
- * Rejects a status mutation targeting a missing, deleted, or invisible report.
1223
- * 不存在・論理削除済み・不可視区分の日報に対する状態更新を拒否する処理。
1224
- *
1225
- * 認可失敗と不存在を区別せず一律 `Not Found` にする (存在オラクルの回避)。従来は hub を
1226
- * 一切検証せず任意の `reportHubId` へ upsert できたため、その穴も同時に塞ぐ。
1227
- *
1228
- * @param reportHubId Hub id. 対象 hub の ID。
1229
- * @param scope Viewer scope carrying the visible source types. 可視 sourceType を運ぶ閲覧範囲。
1230
- * @returns Nothing; throws when rejected. なし (拒否時は例外送出)。
1231
- */
1232
- const assertHubMutable = async (scope: DailyReportViewerScope, reportHubId: number, userId: number): Promise<void> => {
1233
- const hub = await findHubGateColumns(reportHubId, userId)
1234
- if (!hub || hub.deletedAt || !isSourceTypeVisible(scope.visible, hub.sourceType)) {
1235
- throw new Error("Not Found")
1236
- }
1237
- }
1238
-
1239
- /**
1240
- * Sets the starred status of a daily report for a user.
1241
- * ユーザーの日報スター状態を設定する。
1242
- *
1243
- * @param userId Internal numeric user id. 内部数値ユーザー ID。
1244
- * @param reportHubId Hub id. 対象 hub の ID。
1245
- * @param businessDate Business date for cache invalidation. キャッシュ無効化に使う営業日。
1246
- * @param isStarred Desired star state. 設定するスター状態。
1247
- * @param clientTempId Optimistic-update correlation id. 楽観更新の相関 ID。
1248
- * @param scope Viewer scope carrying the visible source types. 可視 sourceType を運ぶ閲覧範囲。
1249
- * @returns Updated status row. 更新後の状態行。
1250
- */
1251
- const setStarStatus = async (scope: DailyReportViewerScope, userId: number, reportHubId: number, businessDate: string | null, isStarred: boolean, clientTempId: string): Promise<DailyReportUserStatusRow> => {
1252
- // 不可視区分・不存在・削除済みへの書き込みを先に弾く
1253
- await assertHubMutable(scope, reportHubId, userId)
1254
-
1255
- const existing = await db
1256
- .select<DailyReportUserStatusRow>()
1257
- .top(1)
1258
- .from(DailyReportUserStatus)
1259
- .where(and(eq(DailyReportUserStatus.hubId, reportHubId), eq(DailyReportUserStatus.userId, userId)))
1260
-
1261
- let updatedStatus: DailyReportUserStatusRow
1262
-
1263
- if (existing.length > 0) {
1264
- if (existing[0].isStarred !== isStarred) {
1265
- const rows = await db
1266
- .update<DailyReportUserStatusRow>(DailyReportUserStatus)
1267
- .set({
1268
- isStarred: isStarred,
1269
- updatedAt: new Date(),
1270
- updatedBy: String(userId),
1271
- })
1272
- .output()
1273
- .where(and(eq(DailyReportUserStatus.hubId, reportHubId), eq(DailyReportUserStatus.userId, userId)))
1274
- updatedStatus = rows[0]
1275
- } else {
1276
- updatedStatus = existing[0]
1277
- }
1278
- } else {
1279
- const rows = await db
1280
- .insert<DailyReportUserStatusRow>(DailyReportUserStatus)
1281
- .output()
1282
- .values({
1283
- hubId: reportHubId,
1284
- userId,
1285
- isStarred: isStarred,
1286
- isRead: false,
1287
- createdAt: new Date(),
1288
- createdBy: String(userId),
1289
- updatedAt: new Date(),
1290
- updatedBy: String(userId),
1291
- })
1292
- updatedStatus = rows[0]
1293
- }
1294
-
1295
- if (businessDate) {
1296
- const normalizedDate = normalizeBusinessDateKey(businessDate)
1297
- if (normalizedDate) {
1298
- // 可視集合ダイジェスト付きキーを取りこぼさないよう接頭辞で消す
1299
- sqlResultCache.invalidatePrefix(dateUserPrefix(normalizedDate, userId))
1300
- await incrementRedisEpoch(`${DAILY_REPORT_DATE_EPOCH_PREFIX}${normalizedDate}`)
1301
- }
1302
- }
1303
- sqlResultCache.invalidate(`daily-report:detail:${reportHubId}`)
1304
- // 可視集合ダイジェスト付きキーを取りこぼさないよう接頭辞で消す
1305
- sqlResultCache.invalidatePrefix(detailUserPrefix(reportHubId, userId))
1306
- await incrementRedisEpoch(`${DAILY_REPORT_DETAIL_EPOCH_PREFIX}${reportHubId}`)
1307
-
1308
- await publishToSseStream(
1309
- statusUpdateMessageSchema.parse({
1310
- type: "status-update",
1311
- reportHubId: reportHubId,
1312
- recipientRawUserId: userId,
1313
- statusType: "star",
1314
- value: isStarred,
1315
- clientTempId,
1316
- }),
1317
- "setStarStatus",
1318
- )
1319
-
1320
- return updatedStatus
1321
- }
1322
-
1323
- /**
1324
- * Sets the read status of a daily report for a user.
1325
- * ユーザーの日報既読状態を設定する。
1326
- *
1327
- * @param userId Internal numeric user id. 内部数値ユーザー ID。
1328
- * @param reportHubId Hub id. 対象 hub の ID。
1329
- * @param businessDate Business date for cache invalidation. キャッシュ無効化に使う営業日。
1330
- * @param isRead Desired read state. 設定する既読状態。
1331
- * @param clientTempId Optimistic-update correlation id. 楽観更新の相関 ID。
1332
- * @param scope Viewer scope carrying the visible source types. 可視 sourceType を運ぶ閲覧範囲。
1333
- * @returns Updated status row. 更新後の状態行。
1334
- */
1335
- const setReadStatus = async (scope: DailyReportViewerScope, userId: number, reportHubId: number, businessDate: string | null, isRead: boolean, clientTempId: string): Promise<DailyReportUserStatusRow> => {
1336
- // 不可視区分・不存在・削除済みへの書き込みを先に弾く
1337
- await assertHubMutable(scope, reportHubId, userId)
1338
-
1339
- const existing = await db
1340
- .select<DailyReportUserStatusRow>()
1341
- .top(1)
1342
- .from(DailyReportUserStatus)
1343
- .where(and(eq(DailyReportUserStatus.hubId, reportHubId), eq(DailyReportUserStatus.userId, userId)))
1344
-
1345
- let updatedStatus: DailyReportUserStatusRow
1346
-
1347
- if (existing.length > 0) {
1348
- if (existing[0].isRead !== isRead) {
1349
- const rows = await db
1350
- .update<DailyReportUserStatusRow>(DailyReportUserStatus)
1351
- .set({
1352
- isRead: isRead,
1353
- updatedAt: new Date(),
1354
- updatedBy: String(userId),
1355
- })
1356
- .output()
1357
- .where(and(eq(DailyReportUserStatus.hubId, reportHubId), eq(DailyReportUserStatus.userId, userId)))
1358
- updatedStatus = rows[0]
1359
- } else {
1360
- updatedStatus = existing[0]
1361
- }
1362
- } else {
1363
- const rows = await db
1364
- .insert<DailyReportUserStatusRow>(DailyReportUserStatus)
1365
- .output()
1366
- .values({
1367
- hubId: reportHubId,
1368
- userId,
1369
- isRead: isRead,
1370
- isStarred: false,
1371
- createdAt: new Date(),
1372
- createdBy: String(userId),
1373
- updatedAt: new Date(),
1374
- updatedBy: String(userId),
1375
- })
1376
- updatedStatus = rows[0]
1377
- }
1378
-
1379
- if (businessDate) {
1380
- const normalizedDate = normalizeBusinessDateKey(businessDate)
1381
- if (normalizedDate) {
1382
- // 可視集合ダイジェスト付きキーを取りこぼさないよう接頭辞で消す
1383
- sqlResultCache.invalidatePrefix(dateUserPrefix(normalizedDate, userId))
1384
- await incrementRedisEpoch(`${DAILY_REPORT_DATE_EPOCH_PREFIX}${normalizedDate}`)
1385
- }
1386
- }
1387
- sqlResultCache.invalidate(`daily-report:detail:${reportHubId}`)
1388
- // 可視集合ダイジェスト付きキーを取りこぼさないよう接頭辞で消す
1389
- sqlResultCache.invalidatePrefix(detailUserPrefix(reportHubId, userId))
1390
- await incrementRedisEpoch(`${DAILY_REPORT_DETAIL_EPOCH_PREFIX}${reportHubId}`)
1391
-
1392
- await publishToSseStream(
1393
- statusUpdateMessageSchema.parse({
1394
- type: "status-update",
1395
- reportHubId: reportHubId,
1396
- recipientRawUserId: userId,
1397
- statusType: "read",
1398
- value: isRead,
1399
- clientTempId,
1400
- }),
1401
- "setReadStatus",
1402
- )
1403
-
1404
- return updatedStatus
1405
- }
1406
-
1407
- /**
1408
- * Resolves the SSE recipient for comment events on a possibly-draft report.
1409
- * 下書きの可能性がある日報のコメントイベントについて SSE の宛先を解決する処理。
1410
- *
1411
- * 下書きは所有者にしか見えないため、下書きラベルが付いている間はコメント本文も
1412
- * 所有者だけに配信する (無指定だと本文が全ユーザーへブロードキャストされる)。
1413
- * 公開済み (下書きラベル無し) は全員配信 = undefined を返す。
1414
- *
1415
- * @param hubId Parent hub id. 親日報の hub ID。
1416
- * @param ownerUserId Hub owner's raw user id. 所有者の内部ユーザー ID。
1417
- * @returns Recipient raw user id, or undefined to broadcast. 宛先 (全員配信は undefined)。
1418
- */
1419
- const resolveCommentRecipient = async (hubId: number, ownerUserId: number | null): Promise<number | undefined> => {
1420
- // 所有者不明 (共有日報など) は従来どおり全員配信
1421
- if (ownerUserId === null) return undefined
1422
- const draftIds = await getDraftLabelJoinIds()
1423
- const [rel] = await db
1424
- .select<{ hubId: number }>({ hubId: DailyReportHub_Label.hubId })
1425
- .top(1)
1426
- .from(DailyReportHub_Label)
1427
- .where(and(eq(DailyReportHub_Label.hubId, hubId), inArray(DailyReportHub_Label.labelId, draftIds)))
1428
- return rel ? ownerUserId : undefined
1429
- }
1430
-
1431
- /**
1432
- * Adds a comment to a daily report.
1433
- * 日報にコメントを追加する。
1434
- *
1435
- * @param userId Internal numeric user id. 内部数値ユーザー ID。
1436
- * @param reportHubId Hub id. 対象 hub の ID。
1437
- * @param content Comment body. コメント本文。
1438
- * @param businessDate Business date for cache invalidation. キャッシュ無効化に使う営業日。
1439
- * @param clientTempId Optimistic-update correlation id. 楽観更新の相関 ID。
1440
- * @param scope Viewer scope carrying the visible source types. 可視 sourceType を運ぶ閲覧範囲。
1441
- * @returns The created comment. 追加されたコメント。
1442
- */
1443
- const addComment = async (scope: DailyReportViewerScope, userId: number, reportHubId: number, content: string, businessDate: string | null, clientTempId: string): Promise<DailyReportPostedComment> => {
1444
- const [hub] = await db
1445
- .select<{ sourceType: string; deletedAt: Date | string | null; userId: number | null }>({ sourceType: DailyReportHub.sourceType, deletedAt: DailyReportHub.deletedAt, userId: DailyReportHub.userId })
1446
- .top(1)
1447
- .from(DailyReportHub)
1448
- .where(eq(DailyReportHub.id, reportHubId))
1449
- // 存在しない / 論理削除済みの日報にはコメントを付けない (以前は hub 未取得でも
1450
- // フォールスルーして孤児コメントを INSERT していた)
1451
- if (!hub || hub.deletedAt) {
1452
- throw new Error("Not Found")
1453
- }
1454
- // 不可視区分・他人の下書きにはコメントできない。認可失敗と不存在を区別せず Not Found にする
1455
- // (存在オラクルの回避)。読み取り経路と同一の可視性述語を共通ゲートで適用する
1456
- await assertHubMutable(scope, reportHubId, userId)
1457
- // コメント次元が拒否する区分には記入させない。本文は読めても書けない状態を表現するため、
1458
- // 不存在 (Not Found) ではなく「制限」として返す — 閲覧者は当該日報を見えているので
1459
- // 不存在を装っても存在オラクルにはならず、嘘をつくだけ UI の説明性が落ちる
1460
- if (!isSourceTypeVisible(scope.commentable, hub.sourceType)) {
1461
- throw new Error("Comments are restricted by the viewer's comment permission")
1462
- }
1463
- if (hub.sourceType.toLowerCase() !== "internal") {
1464
- throw new Error("Comments are restricted for external daily report sources")
1465
- }
1466
- // 下書きなら SSE を所有者宛に絞る (本文の全員配信を防ぐ)
1467
- const recipientRawUserId = await resolveCommentRecipient(reportHubId, hub.userId)
1468
-
1469
- const [inserted] = await db
1470
- .insert<DailyReportCommentRow>(DailyReportCommentModel)
1471
- .output()
1472
- .values({
1473
- hubId: reportHubId,
1474
- userId,
1475
- body: content,
1476
- createdAt: new Date(),
1477
- createdBy: String(userId),
1478
- updatedAt: new Date(),
1479
- updatedBy: String(userId),
1480
- })
1481
-
1482
- const [user] = await db.select<{ displayName: string | null }>({ displayName: users.displayName }).top(1).from(users).where(eq(users.id, userId))
1483
- const userName = user?.displayName ?? "Unknown"
1484
-
1485
- // コメントは全ユーザーに表示されるため、date キャッシュも全ユーザー分を無効化
1486
- if (businessDate) {
1487
- const normalizedDate = normalizeBusinessDateKey(businessDate)
1488
- if (normalizedDate) {
1489
- sqlResultCache.invalidatePrefix(`daily-report:date:${normalizedDate}:user:`)
1490
- await incrementRedisEpoch(`${DAILY_REPORT_DATE_EPOCH_PREFIX}${normalizedDate}`)
1491
- }
1492
- }
1493
- sqlResultCache.invalidate(`daily-report:detail:${reportHubId}`)
1494
- sqlResultCache.invalidatePrefix(`daily-report:detail:${reportHubId}:user:`)
1495
- await incrementRedisEpoch(`${DAILY_REPORT_DETAIL_EPOCH_PREFIX}${reportHubId}`)
1496
-
1497
- const commentItem: DailyReportPostedComment = {
1498
- id: inserted.id,
1499
- userId: encodeUserId(inserted.userId),
1500
- userName: userName,
1501
- content: inserted.body,
1502
- createdAt: formatDateValue(inserted.createdAt, "YYYY-MM-DD HH:mm:ss") ?? "",
1503
- isMine: true,
1504
- }
1505
-
1506
- await publishToSseStream(
1507
- commentAddMessageSchema.parse({
1508
- type: "comment-add",
1509
- reportHubId: reportHubId,
1510
- comment: commentItem,
1511
- clientTempId,
1512
- recipientRawUserId,
1513
- // 受信側の区分フィルタ用 (旧エントリ互換のためスキーマ上は任意)
1514
- sourceType: hub.sourceType,
1515
- }),
1516
- "addComment",
1517
- )
1518
-
1519
- return commentItem
1520
- }
1521
-
1522
- /**
1523
- * Finds a daily report comment by ID.
1524
- * 日報コメントをIDで検索する。
1525
- */
1526
- const findDailyReportCommentById = async (tx: DailyReportDb, commentId: number) => {
1527
- return await tx.select<DailyReportCommentRow>().top(1).from(DailyReportCommentModel).where(eq(DailyReportCommentModel.id, commentId))
1528
- }
1529
-
1530
- /**
1531
- * Deletes a comment from a daily report.
1532
- * 日報のコメントを削除する。
1533
- *
1534
- * @param userId Internal numeric user id. 内部数値ユーザー ID。
1535
- * @param reportHubId Hub id. 対象 hub の ID。
1536
- * @param commentId Comment id to delete. 削除対象のコメント ID。
1537
- * @param businessDate Business date for cache invalidation. キャッシュ無効化に使う営業日。
1538
- * @param clientTempId Optimistic-update correlation id. 楽観更新の相関 ID。
1539
- * @param scope Viewer scope carrying the visible source types. 可視 sourceType を運ぶ閲覧範囲。
1540
- * @returns Nothing. なし。
1541
- */
1542
- const deleteComment = async (scope: DailyReportViewerScope, userId: number, reportHubId: number, commentId: number, businessDate: string | null, clientTempId: string): Promise<void> => {
1543
- const comments = await findDailyReportCommentById(db, commentId)
1544
- if (comments.length === 0) {
1545
- throw new Error("Not Found")
1546
- }
1547
-
1548
- // 渡された reportHubId がコメントの実際の所属と一致することを確認する。
1549
- // 不一致のまま進むと、無関係な日報のキャッシュ無効化と SSE 配信を引き起こす。
1550
- // ❗ 所有者チェックより**前**に置く。後ろだと、攻撃者が自分に可視な hubId を添えて任意の
1551
- // commentId を試すことで「他人のコメント = 403 / 存在しない = 404」と応答が割れ、
1552
- // コメントの存在を列挙できてしまう
1553
- if (comments[0].hubId !== reportHubId) {
1554
- throw new Error("Not Found")
1555
- }
1556
-
1557
- // ❗ 区分ゲートも所有者チェックより前に置く。参照するのは**コメント自身の所属 hub** で
1558
- // あり、呼び出し側が渡した ID ではない (上で一致を確認済み)。hub 行が引けない場合も
1559
- // fail-close で Not Found にする (assertHubMutable と同一の規約)
1560
- // ❗ ここで見るのは本文次元 (`scope.visible`) であって**コメント次元ではない**。意図的。
1561
- // 自分のコメントは常に可視という契約 (mapHubRecord 参照) の対を成す規則であり、
1562
- // コメント次元でゲートすると権限剥奪の瞬間に既存の自分のコメントが恒久的に削除不能に
1563
- // なる。所有者チェックが単独で十分な防御になっている
1564
- const [hubForDelete] = await db.select<{ userId: number | null; sourceType: string }>({ userId: DailyReportHub.userId, sourceType: DailyReportHub.sourceType }).top(1).from(DailyReportHub).where(eq(DailyReportHub.id, comments[0].hubId))
1565
- if (!(hubForDelete && isSourceTypeVisible(scope.visible, hubForDelete.sourceType))) {
1566
- throw new Error("Not Found")
1567
- }
1568
-
1569
- if (comments[0].userId !== userId) {
1570
- throw new Error("Unauthorized")
1571
- }
1572
-
1573
- // 下書きなら削除イベントも所有者宛に絞る (comment-add と対称)
1574
- const recipientRawUserId = await resolveCommentRecipient(reportHubId, hubForDelete.userId)
1575
-
1576
- await db.delete(DailyReportCommentModel).where(eq(DailyReportCommentModel.id, commentId))
1577
-
1578
- // コメントは全ユーザーに表示されるため、date キャッシュも全ユーザー分を無効化
1579
- if (businessDate) {
1580
- const normalizedDate = normalizeBusinessDateKey(businessDate)
1581
- if (normalizedDate) {
1582
- sqlResultCache.invalidatePrefix(`daily-report:date:${normalizedDate}:user:`)
1583
- await incrementRedisEpoch(`${DAILY_REPORT_DATE_EPOCH_PREFIX}${normalizedDate}`)
1584
- }
1585
- }
1586
- sqlResultCache.invalidate(`daily-report:detail:${reportHubId}`)
1587
- sqlResultCache.invalidatePrefix(`daily-report:detail:${reportHubId}:user:`)
1588
- await incrementRedisEpoch(`${DAILY_REPORT_DETAIL_EPOCH_PREFIX}${reportHubId}`)
1589
-
1590
- await publishToSseStream(
1591
- commentDeleteMessageSchema.parse({
1592
- type: "comment-delete",
1593
- reportHubId: reportHubId,
1594
- commentId,
1595
- clientTempId,
1596
- recipientRawUserId,
1597
- // 受信側の区分フィルタ用 (hub 行が引けない場合は undefined = 旧エントリ相当の扱い)
1598
- sourceType: hubForDelete?.sourceType,
1599
- }),
1600
- "deleteComment",
1601
- )
1602
- }
1603
-
1604
- /**
1605
- * Creates a new draft daily report.
1606
- * 新しい日報(下書き)を作成する。
1607
- */
1608
- const createDailyReport = async (userId: number, businessDate: string, clientTempId: string): Promise<DailyReportDetail> => {
1609
- logger.info("createDailyReport called", { userId, businessDate })
1610
-
1611
- // ユーザー名取得
1612
- const [user] = await db.select<{ displayName: string | null }>({ displayName: users.displayName }).from(users).where(eq(users.id, userId))
1613
- const userName = user?.displayName ?? encodeUserId(userId)
1614
-
1615
- try {
1616
- const result = await inTransaction(async (tx) => {
1617
- logger.info("Starting transaction")
1618
- // 1. Hub作成
1619
- const sourceId = `internal-temp-${Date.now()}-${Math.random()}` // 一時的なID
1620
- const [hub] = await tx
1621
- .insert<DailyReportHubRow>(DailyReportHub)
1622
- .output()
1623
- .values({
1624
- sourceType: "Internal",
1625
- sourceId: sourceId,
1626
- businessDate: new Date(businessDate),
1627
- userId: userId,
1628
- title: "(無題)",
1629
- createdAt: new Date(),
1630
- createdBy: String(userId),
1631
- updatedAt: new Date(),
1632
- updatedBy: String(userId),
1633
- })
1634
-
1635
- // sourceId を id と同じ値に更新 (Internal の場合の正規化)
1636
- await tx
1637
- .update(DailyReportHub)
1638
- .set({ sourceId: String(hub.id) })
1639
- .where(eq(DailyReportHub.id, hub.id))
1640
-
1641
- logger.info("Hub created", hub)
1642
-
1643
- // 2. Internal作成
1644
- await tx.insert(DailyReportInternal).values({
1645
- hubId: hub.id,
1646
- body: "",
1647
- createdAt: new Date(),
1648
- createdBy: String(userId),
1649
- updatedAt: new Date(),
1650
- updatedBy: String(userId),
1651
- })
1652
- logger.info("Internal created")
1653
-
1654
- // 3. ラベル付与
1655
- logger.info("Calling getDraftLabelId")
1656
- const draftLabelId = await getDraftLabelId()
1657
- logger.info("draftLabelId", draftLabelId)
1658
- const labels: DailyReportLabelDef[] = []
1659
- if (draftLabelId) {
1660
- await tx.insert(DailyReportHub_Label).values({
1661
- hubId: hub.id,
1662
- labelId: draftLabelId,
1663
- createdAt: new Date(),
1664
- createdBy: String(userId),
1665
- })
1666
- labels.push({ id: draftLabelId, name: draftLabelName, color: null })
1667
- }
1668
- logger.info("Label assigned")
1669
-
1670
- // 4. 詳細オブジェクト構築
1671
- return {
1672
- reportHubId: hub.id,
1673
- date: businessDate,
1674
- createdAt: formatDateValue(hub.createdAt, "YYYY-MM-DD HH:mm:ss"),
1675
- author: userName,
1676
- userId: encodeUserId(userId),
1677
- sourceType: "Internal",
1678
- employeeName: userName,
1679
- // 監査列と同様、クライアント公開時は生内部 ID を難読化する (mapHubRecord と整合)
1680
- updatedBy: encodeUserId(userId),
1681
- updatedAt: formatDateValue(hub.updatedAt, "YYYY-MM-DD HH:mm:ss"),
1682
- category: null,
1683
- creationCategory: null,
1684
- visitTimeFrom: null,
1685
- visitTimeTo: null,
1686
- customerName: null,
1687
- interviewers: [],
1688
- subject: hub.title,
1689
- content: "",
1690
- externalComments: [],
1691
- isRead: true, // 自分で作ったので既読
1692
- isStarred: false,
1693
- labels: labels,
1694
- postedComments: [],
1695
- // 新規作成直後の日報に添付は存在しない
1696
- attachments: [],
1697
- }
1698
- })
1699
-
1700
- // キャッシュ無効化
1701
- const normalizedBusinessDate = normalizeBusinessDateKey(businessDate)
1702
- if (normalizedBusinessDate) {
1703
- // 可視集合ダイジェスト付きキーを取りこぼさないよう接頭辞で消す
1704
- sqlResultCache.invalidatePrefix(dateUserPrefix(normalizedBusinessDate, userId))
1705
- }
1706
- sqlResultCache.invalidatePrefix(DAILY_REPORT_BUSINESS_DATE_CACHE_PREFIX)
1707
- // ❗ createDailyReport には ids の広域 prefix 無効化が無く、この 1 行が作成者の ID 一覧を
1708
- // 落とす唯一の経路。redis 未注入の配備では epoch も進まないため、ここを消すと
1709
- // 作成した日報が自分の一覧から TTL 分 (既定 180 秒) 消える。接頭辞化のみ行う
1710
- sqlResultCache.invalidatePrefix(idsUserPrefix(userId))
1711
- // ❗ 順序重要: invalidate → incrementRedisEpoch → publishToSseStream
1712
- await incrementRedisEpoch(DAILY_REPORT_IDS_EPOCH_KEY)
1713
- if (normalizedBusinessDate) {
1714
- await incrementRedisEpoch(`${DAILY_REPORT_DATE_EPOCH_PREFIX}${normalizedBusinessDate}`)
1715
- }
1716
-
1717
- // ❗ 明示 null (無制限)。所有者が自分で作った日報を取り直す経路なので、消費側の可視集合に
1718
- // 作成区分 ("Internal") が含まれなくても取得と SSE 配信を壊さない。区分の出し分けは
1719
- // 受信側の SSE フィルタと次回読み取り時のサーバーフィルタが担う
1720
- const fullDetail = await getDailyReportDetailById(DailyReportViewerScope.unrestricted("post-write self read: the writer always sees the row they just wrote"), result.reportHubId, userId, { forceRefresh: true })
1721
-
1722
- if (fullDetail) {
1723
- // 下書き判定
1724
- // 判定は代表 ID ではなく下書きラベル**全体**で行う (2 つ目の下書き名を見落とさない)
1725
- const draftLabelIds = await getDraftLabelIds()
1726
- const isDraft = fullDetail.labels.some((l) => draftLabelIds.includes(l.id))
1727
- await publishToSseStream(
1728
- reportCreateMessageSchema.parse({
1729
- type: "report-create",
1730
- reportHubId: fullDetail.reportHubId,
1731
- report: fullDetail,
1732
- clientTempId,
1733
- recipientRawUserId: isDraft ? userId : undefined,
1734
- }),
1735
- "createDailyReport",
1736
- )
1737
- }
1738
-
1739
- return fullDetail ?? result
1740
- } catch (e) {
1741
- logger.error("Error in createDailyReport", e)
1742
- throw e
1743
- }
1744
- }
1745
-
1746
- // --- Internal Repository Layer ---
1747
-
1748
- /**
1749
- * Finds a DailyReportHub by ID.
1750
- * 日報HubをIDで検索する。
1751
- */
1752
- const findDailyReportHubById = async (tx: DailyReportDb, reportHubId: number) => {
1753
- return await tx.select<DailyReportHubRow>().top(1).from(DailyReportHub).where(eq(DailyReportHub.id, reportHubId))
1754
- }
1755
-
1756
- /**
1757
- * Updates a DailyReportHub.
1758
- * 日報Hubを更新する。
1759
- */
1760
- const updateDailyReportHub = async (tx: DailyReportDb, reportHubId: number, data: Partial<DailyReportHubRow>) => {
1761
- await tx.update(DailyReportHub).set(data).where(eq(DailyReportHub.id, reportHubId))
1762
- }
1763
-
1764
- /**
1765
- * Updates a DailyReportInternal.
1766
- * 日報Internalを更新する。
1767
- */
1768
- const updateDailyReportInternal = async (tx: DailyReportDb, hubId: number, data: Partial<DailyReportInternalRow>) => {
1769
- await tx.update(DailyReportInternal).set(data).where(eq(DailyReportInternal.hubId, hubId))
1770
- }
1771
-
1772
- /**
1773
- * Deletes a label from a DailyReportHub.
1774
- * 日報Hubからラベルを削除する。
1775
- */
1776
- const deleteDailyReportLabel = async (tx: DailyReportDb, hubId: number, labelId: number) => {
1777
- await tx.delete(DailyReportHub_Label).where(and(eq(DailyReportHub_Label.hubId, hubId), eq(DailyReportHub_Label.labelId, labelId)))
1778
- }
1779
-
1780
- // --- Service Layer ---
1781
-
1782
- /**
1783
- * Logically deletes a daily report.
1784
- * 日報を論理削除する。
1785
- */
1786
- const deleteDailyReport = async (reportHubId: number, userId: number, clientTempId: string): Promise<void> => {
1787
- const report = await findDailyReportHubById(db, reportHubId)
1788
- if (!report.length || report[0].deletedAt) {
1789
- throw new Error("Not Found")
1790
- }
1791
- if (report[0].userId !== userId) {
1792
- throw new Error("Unauthorized")
1793
- }
1794
-
1795
- await updateDailyReportHub(db, reportHubId, {
1796
- deletedAt: new Date(),
1797
- deletedBy: String(userId),
1798
- updatedAt: new Date(),
1799
- updatedBy: String(userId),
1800
- })
1801
-
1802
- // キャッシュ無効化
1803
- sqlResultCache.invalidatePrefix(`daily-report:detail:${reportHubId}:user:`)
1804
- sqlResultCache.invalidatePrefix(DAILY_REPORT_BUSINESS_DATE_CACHE_PREFIX)
1805
- // 削除されたレポートは全ユーザーの一覧から消えるため、全ユーザーの IDs キャッシュを invalidate
1806
- sqlResultCache.invalidatePrefix(DAILY_REPORT_IDS_CACHE_KEY)
1807
- // ❗ 順序重要: invalidatePrefix → incrementRedisEpoch → publishToSseStream
1808
- await incrementRedisEpoch(DAILY_REPORT_IDS_EPOCH_KEY)
1809
- if (report[0].businessDate) {
1810
- const normalizedDate = normalizeBusinessDateKey(formatDateValue(report[0].businessDate, "YYYY-MM-DD"))
1811
- if (normalizedDate) {
1812
- await incrementRedisEpoch(`${DAILY_REPORT_DATE_EPOCH_PREFIX}${normalizedDate}`)
1813
- }
1814
- }
1815
-
1816
- await publishToSseStream(
1817
- reportDeleteMessageSchema.parse({
1818
- type: "report-delete",
1819
- reportHubId: reportHubId,
1820
- clientTempId,
1821
- // 受信側の区分フィルタ用 (旧エントリ互換のためスキーマ上は任意)
1822
- sourceType: report[0].sourceType,
1823
- }),
1824
- "deleteDailyReport",
1825
- )
1826
- }
1827
-
1828
- /**
1829
- * Updates an existing daily report.
1830
- * 日報を更新する。
1831
- */
1832
- const updateDailyReport = async (reportHubId: number, userId: number, data: { title?: string; content?: string }, clientTempId: string): Promise<void> => {
1833
- const report = await findDailyReportHubById(db, reportHubId)
1834
- if (!report.length || report[0].deletedAt) {
1835
- throw new Error("Not Found")
1836
- }
1837
- if (report[0].userId !== userId) {
1838
- throw new Error("Unauthorized")
1839
- }
1840
-
1841
- await inTransaction(async (tx) => {
1842
- if (data.title !== undefined) {
1843
- await updateDailyReportHub(tx, reportHubId, {
1844
- title: data.title,
1845
- updatedAt: new Date(),
1846
- updatedBy: String(userId),
1847
- })
1848
- }
1849
- if (data.content !== undefined) {
1850
- await updateDailyReportInternal(tx, reportHubId, {
1851
- body: data.content,
1852
- updatedAt: new Date(),
1853
- updatedBy: String(userId),
1854
- })
1855
- }
1856
- })
1857
-
1858
- // キャッシュ無効化: 一覧プレビューは詳細キャッシュから引くため、詳細を全プロセス横断で落とす。
1859
- // 以前は読み取りに使われないプレフィックスを無効化し epoch も上げていなかったため、
1860
- // 編集後の件名/本文が他ワーカーで古いまま配信されていた
1861
- sqlResultCache.invalidate(`daily-report:detail:${reportHubId}`)
1862
- sqlResultCache.invalidatePrefix(`daily-report:detail:${reportHubId}:user:`)
1863
- sqlResultCache.invalidatePrefix(DAILY_REPORT_BUSINESS_DATE_CACHE_PREFIX)
1864
- await incrementRedisEpoch(`${DAILY_REPORT_DETAIL_EPOCH_PREFIX}${reportHubId}`)
1865
- // 編集内容は全ユーザーの一覧に映るため、営業日別一覧は全ユーザー分 + 全ワーカー横断で
1866
- // 無効化する (addComment と同じ流儀。編集者本人のキーだけでは他者へ古い件名が残る)
1867
- const editedBusinessDate = normalizeBusinessDateKey(formatDateValue(report[0].businessDate, "YYYY-MM-DD"))
1868
- if (editedBusinessDate) {
1869
- sqlResultCache.invalidatePrefix(`daily-report:date:${editedBusinessDate}:user:`)
1870
- await incrementRedisEpoch(`${DAILY_REPORT_DATE_EPOCH_PREFIX}${editedBusinessDate}`)
1871
- }
1872
-
1873
- // ❗ 明示 null (無制限)。所有者が自分の日報を取り直す経路であり、区分の出し分けは
1874
- // 受信側の SSE フィルタと次回読み取り時のサーバーフィルタが担う
1875
- const fullDetail = await getDailyReportDetailById(DailyReportViewerScope.unrestricted("post-write self read: the writer always sees the row they just wrote"), reportHubId, userId, { forceRefresh: true })
1876
- if (fullDetail) {
1877
- // 下書き判定
1878
- // 判定は代表 ID ではなく下書きラベル**全体**で行う (2 つ目の下書き名を見落とさない)
1879
- const draftLabelIds = await getDraftLabelIds()
1880
- const isDraft = fullDetail.labels.some((l) => draftLabelIds.includes(l.id))
1881
- await publishToSseStream(
1882
- reportUpdateMessageSchema.parse({
1883
- type: "report-update",
1884
- reportHubId: fullDetail.reportHubId,
1885
- report: fullDetail,
1886
- clientTempId,
1887
- recipientRawUserId: isDraft ? userId : undefined,
1888
- }),
1889
- "updateDailyReport",
1890
- )
1891
- }
1892
- }
1893
-
1894
- /**
1895
- * Publishes a draft daily report.
1896
- * 日報を公開する(下書きラベルを削除)。
1897
- */
1898
- const publishDailyReport = async (reportHubId: number, userId: number, clientTempId: string): Promise<DailyReportDetail | null> => {
1899
- const report = await findDailyReportHubById(db, reportHubId)
1900
- if (!report.length) {
1901
- throw new Error("Not Found")
1902
- }
1903
- if (report[0].userId !== userId) {
1904
- throw new Error("Unauthorized")
1905
- }
1906
-
1907
- const draftLabelId = await getDraftLabelId()
1908
- if (!draftLabelId) return null
1909
-
1910
- await deleteDailyReportLabel(db, reportHubId, draftLabelId)
1911
-
1912
- // キャッシュ無効化
1913
- sqlResultCache.invalidatePrefix(`daily-report:detail:${reportHubId}:user:`)
1914
- sqlResultCache.invalidatePrefix(DAILY_REPORT_BUSINESS_DATE_CACHE_PREFIX)
1915
- // 公開によりドラフトが全ユーザーに可視化されるため、全ユーザーの IDs キャッシュを invalidate
1916
- sqlResultCache.invalidatePrefix(DAILY_REPORT_IDS_CACHE_KEY)
1917
- // ❗ 順序重要: invalidatePrefix → incrementRedisEpoch → publishToSseStream
1918
- await incrementRedisEpoch(DAILY_REPORT_IDS_EPOCH_KEY)
1919
- if (report[0].businessDate) {
1920
- const normalizedDate = normalizeBusinessDateKey(formatDateValue(report[0].businessDate, "YYYY-MM-DD"))
1921
- if (normalizedDate) {
1922
- await incrementRedisEpoch(`${DAILY_REPORT_DATE_EPOCH_PREFIX}${normalizedDate}`)
1923
- }
1924
- }
1925
-
1926
- // ユーザーごとの営業日別一覧キャッシュも無効化
1927
- const normalizedBusinessDate = normalizeBusinessDateKey(formatDateValue(report[0].businessDate, "YYYY-MM-DD"))
1928
- if (normalizedBusinessDate) {
1929
- // 可視集合ダイジェスト付きキーを取りこぼさないよう接頭辞で消す
1930
- sqlResultCache.invalidatePrefix(dateUserPrefix(normalizedBusinessDate, userId))
1931
- }
1932
-
1933
- // ❗ 明示 null (無制限)。所有者が自分の日報を取り直す経路であり、区分の出し分けは
1934
- // 受信側の SSE フィルタと次回読み取り時のサーバーフィルタが担う
1935
- const fullDetail = await getDailyReportDetailById(DailyReportViewerScope.unrestricted("post-write self read: the writer always sees the row they just wrote"), reportHubId, userId, { forceRefresh: true })
1936
- if (fullDetail) {
1937
- await publishToSseStream(
1938
- reportPublishMessageSchema.parse({
1939
- type: "report-publish",
1940
- reportHubId: fullDetail.reportHubId,
1941
- report: fullDetail,
1942
- clientTempId,
1943
- }),
1944
- "publishDailyReport",
1945
- )
1946
- }
1947
- return fullDetail ?? null
1948
- }
1949
-
1950
- /**
1951
- * Invalidates the read caches that carry hub-scoped data across every process.
1952
- * hub 単位のデータを載せている読み取りキャッシュを、全プロセス横断で無効化する処理。
1953
- *
1954
- * 用途: 通常の更新経路 (create / update / publish) を通らずに DB を書き換えた場合、
1955
- * 例えば外部システムからの一括取込。プロセス内 Map を消すだけでは稼働中の
1956
- * 別プロセス (別ワーカー・別コンテナ) のキャッシュが TTL まで stale のままになるため、
1957
- * Redis の epoch を進めて全プロセスのキャッシュ行を一斉に無効化する。
1958
- *
1959
- * `clearCache()` との違い: `clearCache()` はプロセス内キャッシュの全消去 + 一覧 ID の
1960
- * epoch のみ進める粗い操作。こちらは対象 hub / 営業日 / 一覧 ID の三系統を epoch まで
1961
- * 含めて狙い撃ちで無効化する。日報本体に紐づくデータを書き換えたときはこちらを使うこと。
1962
- *
1963
- * @param hubIds Hub ids whose detail cache must be dropped. 詳細キャッシュを捨てる hub ID 群。
1964
- * @param businessDates Business dates (YYYY-MM-DD) whose list cache must be dropped. 一覧キャッシュを捨てる営業日群。
1965
- * @returns Nothing. なし。
1966
- */
1967
- const invalidateHubReadCaches = async (hubIds: readonly number[], businessDates: readonly string[]): Promise<void> => {
1968
- sqlResultCache.invalidatePrefix(DAILY_REPORT_BUSINESS_DATE_CACHE_PREFIX)
1969
- // 一覧 ID は「どの行が存在するか」そのもの。hub の行集合を変えうる呼び出し元
1970
- // (取込・移行) がこの関数を頼る以上、詳細・営業日と同時に捨てる。
1971
- // 現在の唯一の呼び出し元 (添付同期) は行集合を変えないため、これは将来の
1972
- // 行集合変更呼び出し元に対する予防である
1973
- sqlResultCache.invalidatePrefix(DAILY_REPORT_IDS_CACHE_KEY)
1974
- await incrementRedisEpoch(DAILY_REPORT_IDS_EPOCH_KEY)
1975
- for (const hubId of new Set(hubIds)) {
1976
- sqlResultCache.invalidatePrefix(`daily-report:detail:${hubId}:user:`)
1977
- await incrementRedisEpoch(`${DAILY_REPORT_DETAIL_EPOCH_PREFIX}${hubId}`)
1978
- }
1979
- for (const businessDate of new Set(businessDates)) {
1980
- const normalized = normalizeBusinessDateKey(businessDate)
1981
- // 正規化できない値で epoch キーを作ると、誰も参照しないキーが増えるだけになる
1982
- if (!normalized) continue
1983
- await incrementRedisEpoch(`${DAILY_REPORT_DATE_EPOCH_PREFIX}${normalized}`)
1984
- }
1985
- }
1986
-
1987
- /**
1988
- * Clears all server-side SQL result and user ID caches.
1989
- * サーバー側のすべての SQL 結果キャッシュおよびユーザー ID キャッシュを全消去する。
1990
- *
1991
- * Redis で進める epoch は一覧 ID のもののみ (プロセス内キャッシュは全系統消える)。
1992
- * 特定 hub / 営業日の epoch まで狙い撃ちで進めたい場合は `invalidateHubReadCaches` を使うこと。
1993
- */
1994
- const clearCache = async (): Promise<void> => {
1995
- sqlResultCache.clearAll()
1996
- userIdCache.clear()
1997
- cachedDraftLabelIds = null
1998
- if (epochs) {
1999
- await incrementRedisEpoch(DAILY_REPORT_IDS_EPOCH_KEY)
2000
- }
2001
- logger.info("[DailyReportService] Server-side DB/SQL caches cleared successfully.")
2002
- }
2003
-
2004
- return {
2005
- // 定数 (アプリ側の互換 export 用)
2006
- streamKey: DAILY_REPORT_SSE_STREAM_KEY,
2007
- streamMaxLen: DAILY_REPORT_SSE_STREAM_MAXLEN,
2008
- // キャッシュクリア
2009
- clearCache,
2010
- invalidateHubReadCaches,
2011
- // ユーザー解決
2012
- getUserIdByExternalId,
2013
- // 参照系
2014
- getDailyReportIdsByExternalId,
2015
- getDailyReportIdsFirstPageByExternalId,
2016
- getDailyReportsByBusinessDate,
2017
- getDailyReportsByBusinessDateByExternalId,
2018
- getDailyReportDetailById,
2019
- getDailyReportDetailByIdByExternalId,
2020
- getDraftLabelId,
2021
- // 添付 (ハンドラーが認可判定と実体消失記録に使う)
2022
- getAttachmentsByHubIds,
2023
- getAttachmentForUser,
2024
- markAttachmentMissing,
2025
- markAttachmentPresent,
2026
- // 更新系
2027
- setStarStatus,
2028
- setReadStatus,
2029
- addComment,
2030
- deleteComment,
2031
- createDailyReport,
2032
- updateDailyReport,
2033
- publishDailyReport,
2034
- deleteDailyReport,
2035
- // リポジトリヘルパー。現時点で消費側の呼び出し元は無く、公開面としてのみ維持している
2036
- // (「統合テストから直接利用されている」という以前の記述は誤り。実測で 0 件)。
2037
- // ❗ どれも**コメント次元を適用しない**素のリポジトリ操作である。詳細を組み立てる用途へ
2038
- // 流用すると mapHubRecord の射影を迂回してコメント本文が出るため、読み取り経路の
2039
- // 素材として使わないこと
2040
- findDailyReportHubById,
2041
- updateDailyReportHub,
2042
- updateDailyReportInternal,
2043
- deleteDailyReportLabel,
2044
- }
2045
- }