@aiquants/daily-report 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +13 -6
  2. package/dist/client.d.mts +443 -23
  3. package/dist/client.d.ts +443 -23
  4. package/dist/client.js +5 -4
  5. package/dist/client.js.map +1 -1
  6. package/dist/client.mjs +5 -4
  7. package/dist/client.mjs.map +1 -1
  8. package/dist/index.d.mts +77 -3
  9. package/dist/index.d.ts +77 -3
  10. package/dist/index.js +1 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +1 -1
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/server.d.mts +436 -6
  15. package/dist/server.d.ts +436 -6
  16. package/dist/server.js +8 -6
  17. package/dist/server.js.map +1 -1
  18. package/dist/server.mjs +8 -6
  19. package/dist/server.mjs.map +1 -1
  20. package/dist/{sse-schema-rbG114od.d.mts → sse-schema-Df49KA7B.d.mts} +643 -249
  21. package/dist/{sse-schema-eXcMG-Ej.d.ts → sse-schema-RHckD7TS.d.ts} +643 -249
  22. package/dist/styles/daily-report.standalone.css +1 -1
  23. package/dist/{types-D1PKubyo.d.mts → types-Ct1ggzy-.d.mts} +30 -1
  24. package/dist/{types-D1PKubyo.d.ts → types-Ct1ggzy-.d.ts} +30 -1
  25. package/package.json +3 -3
  26. package/src/client/components/daily-report-attachment-indicator.spec.tsx +51 -0
  27. package/src/client/components/daily-report-attachment-indicator.tsx +45 -0
  28. package/src/client/components/daily-report-attachment-list.spec.tsx +94 -0
  29. package/src/client/components/daily-report-attachment-list.tsx +112 -0
  30. package/src/client/components/daily-report-detail-list.tsx +14 -7
  31. package/src/client/components/daily-report-ids-stream-status.spec.tsx +93 -0
  32. package/src/client/components/daily-report-ids-stream-status.tsx +67 -0
  33. package/src/client/components/daily-report-list.tsx +19 -11
  34. package/src/client/components/daily-report-page.spec.tsx +119 -0
  35. package/src/client/components/daily-report-page.tsx +56 -68
  36. package/src/client/components/daily-report-resolved-content.tsx +31 -7
  37. package/src/client/components/report-views.spec.tsx +340 -0
  38. package/src/client/config-context.tsx +8 -0
  39. package/src/client/contexts/daily-report-action-context.spec.tsx +262 -0
  40. package/src/client/contexts/daily-report-action-context.tsx +151 -52
  41. package/src/client/hooks/use-animated-number.spec.ts +103 -0
  42. package/src/client/hooks/use-animated-number.ts +69 -0
  43. package/src/client/hooks/use-responsive-layout.spec.ts +74 -0
  44. package/src/client/hooks/use-responsive-layout.ts +56 -0
  45. package/src/client/route-helpers.spec.ts +100 -0
  46. package/src/client/route-helpers.ts +20 -41
  47. package/src/client/streaming/daily-report-ids-stream-client.spec.ts +641 -0
  48. package/src/client/streaming/daily-report-ids-stream-client.ts +599 -0
  49. package/src/client/streaming/daily-report-ids-stream-session.spec.ts +360 -0
  50. package/src/client/streaming/daily-report-ids-stream-session.ts +280 -0
  51. package/src/client/utils/constants.spec.ts +96 -0
  52. package/src/client/utils/constants.ts +47 -2
  53. package/src/client.ts +6 -0
  54. package/src/index.ts +1 -0
  55. package/src/server/handlers.attachment.spec.ts +470 -0
  56. package/src/server/handlers.ids-stream.spec.ts +362 -0
  57. package/src/server/handlers.ts +671 -6
  58. package/src/server/ports.ts +74 -0
  59. package/src/server/schema.ts +80 -5
  60. package/src/server/service.spec.ts +280 -14
  61. package/src/server/service.ts +348 -25
  62. package/src/server/test-helpers/handlers-config.ts +181 -0
  63. package/src/server.ts +16 -1
  64. package/src/shared/ids-stream.ts +92 -0
  65. package/src/shared/sse-schema.spec.ts +50 -0
  66. package/src/shared/sse-schema.ts +27 -0
  67. package/src/shared/types.ts +31 -0
@@ -8,10 +8,10 @@ import { aliasedTable, and, asc, desc, eq, getColumns, inArray, isNull, or, sql
8
8
  import { normalizeBusinessDateKey } from "../shared/business-date"
9
9
  import { createLogger, type DailyReportLogger, LogLevel } from "../shared/logger"
10
10
  import { commentAddMessageSchema, commentDeleteMessageSchema, reportCreateMessageSchema, reportDeleteMessageSchema, reportPublishMessageSchema, reportUpdateMessageSchema, statusUpdateMessageSchema } from "../shared/sse-schema"
11
- import type { DailyReportComment, DailyReportCommentItem, DailyReportDetail, DailyReportInterviewer, DailyReportItem, DailyReportLabelDef } from "../shared/types"
11
+ import type { DailyReportAttachmentSummary, DailyReportComment, DailyReportCommentItem, DailyReportDetail, DailyReportInterviewer, DailyReportItem, DailyReportLabelDef } from "../shared/types"
12
12
  import type { EpochStore, SqlResultCache, SqlResultCacheQueryOptions } from "./cache"
13
13
  import type { DailyReportExternalSource } from "./external-source"
14
- import type { DailyReportEncodeUserId, DailyReportRedisProvider, DailyReportResolveUserId } from "./ports"
14
+ import type { DailyReportEncodeUserId, DailyReportIdCodec, DailyReportRedisProvider, DailyReportResolveUserId } from "./ports"
15
15
  import type { DailyReportTables, DailyReportUserTable } from "./schema"
16
16
 
17
17
  export type { SqlResultCacheQueryOptions }
@@ -113,7 +113,7 @@ const cols = (t: unknown) => getColumns(t as Parameters<typeof getColumns>[0])
113
113
  export type DailyReportServiceConfig = {
114
114
  /** drizzle mssql データベースハンドル。 */
115
115
  db: DailyReportDb
116
- /** 日報 6 テーブル (アプリ既存モデルまたは defineDailyReportSchema 生成物)。 */
116
+ /** 日報 7 テーブル (アプリ既存モデルまたは defineDailyReportSchema 生成物)。`attachment` のみ任意。 */
117
117
  tables: DailyReportTables
118
118
  /** 表示名解決に使う外部ユーザーテーブル ({ id, displayName })。 */
119
119
  userTable: DailyReportUserTable
@@ -145,6 +145,15 @@ export type DailyReportServiceConfig = {
145
145
  businessDateTtlMs?: number
146
146
  /** 外部 ID → 内部 ID のプロセス内キャッシュを無効化 (テスト用)。 */
147
147
  disableUserIdCache?: boolean
148
+ /**
149
+ * 添付 ID の難読化コーデック。
150
+ *
151
+ * 任意にしているのは、添付テーブルを注入しない消費アプリと既存テストのフィクスチャを
152
+ * 壊さないため。未注入なら添付一覧は常に空になる (縮退動作)。
153
+ * ユーザー ID 用のコーデックとは **別インスタンス**を渡すこと。同一だと
154
+ * レスポンス中の `userId` トークンがそのまま添付トークンとして解釈できてしまう。
155
+ */
156
+ attachmentIdCodec?: DailyReportIdCodec
148
157
  /** ロガー (既定は console ベース)。 */
149
158
  logger?: DailyReportLogger
150
159
  }
@@ -264,6 +273,12 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
264
273
  type HubRecord = HubQueryRow & {
265
274
  labels?: RawJsonLabel[]
266
275
  comments?: RawJsonComment[]
276
+ /**
277
+ * 添付ファイル一覧。**任意にしない。**
278
+ * `?` を付けると `mapHubRecord` 側で `?? []` が必要になり、注入を忘れた読み取り経路が
279
+ * 黙って空配列を返す。必須にしておけば注入漏れは全呼び出し箇所でコンパイルエラーになる。
280
+ */
281
+ attachments: DailyReportAttachmentSummary[]
267
282
  }
268
283
 
269
284
  /** 外部ソースアダプタの select 追加フィールドを構築する。 */
@@ -329,9 +344,11 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
329
344
 
330
345
  // 作成区分がアプリ設定の下書き候補に合致する場合、ラベル一覧に下書きラベルが含まれるよう補完
331
346
  const isCreationCategoryDraft = creationCategory ? draftLabelNames.includes(creationCategory) : false
332
- if (isCreationCategoryDraft && cachedDraftLabelId && !labelsRaw.some((l) => l.id === cachedDraftLabelId)) {
347
+ // 同期関数なので DB へは問い合わせず、既に解決済みのキャッシュがある場合だけ補完する
348
+ const cachedPrimaryDraftLabelId = cachedDraftLabelIds?.[0] ?? null
349
+ if (isCreationCategoryDraft && cachedPrimaryDraftLabelId && !labelsRaw.some((l) => l.id === cachedPrimaryDraftLabelId)) {
333
350
  labelsRaw.push({
334
- id: cachedDraftLabelId,
351
+ id: cachedPrimaryDraftLabelId,
335
352
  name: draftLabelName,
336
353
  color: null,
337
354
  })
@@ -364,16 +381,64 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
364
381
  userId: encodeUserId(c.userId),
365
382
  isMine: currentUserId ? c.userId === currentUserId : false,
366
383
  })),
384
+ // `?? []` を書かない。HubRecord 側で必須にしてあるため、注入漏れはここではなく
385
+ // 呼び出し箇所でコンパイルエラーになる (それが検知したい事象)。
386
+ attachments: row.attachments,
367
387
  }
368
388
  }
369
389
 
370
390
  /**
371
391
  * Fetches daily report IDs alongside normalized business dates.
372
392
  * 日報 ID と正規化済み営業日を取得する内部処理。
393
+ *
394
+ * ❗ 可視性述語は他の 3 経路 (営業日一覧 / 詳細取得 / 添付取得) と**意図的に 1 つ緩い**。
395
+ * ここだけ `isNull(DailyReportHub.userId)` を追加で許しており、投稿者を持たない
396
+ * 取込元の日報を一覧から消さないための緩和である。返すのは ID・営業日・ソース種別だけで
397
+ * 本文も添付も含まないため、緩めても内容は漏れない。
398
+ * この差分は無自覚なものではなく、`service.spec.ts` が明示的に表明して固定している。
399
+ * 他の 3 経路をこの形に合わせてはならない (合わせると内容が漏れる)。
373
400
  */
374
401
  const fetchDailyReportIdsByUserId = async (userId: number): Promise<DailyReportItem[]> => {
375
402
  const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
376
- const draftLabelId = await getDraftLabelId()
403
+ const draftLabelJoinIds = await getDraftLabelJoinIds()
404
+
405
+ const result = await db
406
+ .select<{ reportHubId: number; businessDate: Date | string | null; sourceType: string }>({
407
+ reportHubId: DailyReportHub.id,
408
+ businessDate: DailyReportHub.businessDate,
409
+ sourceType: DailyReportHub.sourceType,
410
+ })
411
+ .from(DailyReportHub)
412
+ .leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
413
+ .where(and(isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DailyReportHub.userId), isNull(DraftLabelRelation.hubId))))
414
+ .orderBy(desc(DailyReportHub.businessDate), desc(DailyReportHub.id))
415
+
416
+ return result.map((item) => ({
417
+ ...item,
418
+ businessDate: formatDateValue(item.businessDate, "YYYY-MM-DD"),
419
+ }))
420
+ }
421
+
422
+ /**
423
+ * Fetches only the first page of report ids for time-to-first-render (uncached).
424
+ * 最初の描画までの時間専用に、ID 一覧の先頭ページだけを取得する内部処理 (キャッシュ無し)。
425
+ *
426
+ * `ids-stream` の新規接続でキャッシュミスした場合、全量クエリの確定を待たずに
427
+ * この軽量クエリ (TOP n・インデックス順で n 行走査した時点で打ち切り) の結果を
428
+ * 最初のチャンクとして送出する。可視性述語と並び順は全量クエリと完全同一。
429
+ * `.top(n)` は `.select()` の直後でなければならない (drizzle-mssql の構造的制約)。
430
+ *
431
+ * @param externalId External user id. 外部ユーザー ID。
432
+ * @param limit Page size. ページサイズ。
433
+ * @returns First-page items in stream order. ストリーム順の先頭ページ。
434
+ */
435
+ const getDailyReportIdsFirstPageByExternalId = async (externalId: string, limit: number): Promise<DailyReportItem[]> => {
436
+ const userId = await getUserIdByExternalId(externalId)
437
+ if (!userId) {
438
+ return []
439
+ }
440
+ const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
441
+ const draftLabelJoinIds = await getDraftLabelJoinIds()
377
442
 
378
443
  const result = await db
379
444
  .select<{ reportHubId: number; businessDate: Date | string | null; sourceType: string }>({
@@ -381,8 +446,9 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
381
446
  businessDate: DailyReportHub.businessDate,
382
447
  sourceType: DailyReportHub.sourceType,
383
448
  })
449
+ .top(limit)
384
450
  .from(DailyReportHub)
385
- .leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), eq(DraftLabelRelation.labelId, draftLabelId ?? -1)))
451
+ .leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
386
452
  .where(and(isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DailyReportHub.userId), isNull(DraftLabelRelation.hubId))))
387
453
  .orderBy(desc(DailyReportHub.businessDate), desc(DailyReportHub.id))
388
454
 
@@ -412,7 +478,8 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
412
478
  .where(and(eq(DailyReportHub.businessDate, sql`${normalizedBusinessDate}`), isNull(DailyReportHub.deletedAt)))
413
479
  .orderBy(desc(DailyReportHub.id))
414
480
 
415
- return rows.map((row) => mapHubRecord(row))
481
+ const attachmentsMap = await getAttachmentsByHubIds(rows.map((r) => r.hub.id))
482
+ return rows.map((row) => mapHubRecord({ ...row, attachments: attachmentsMap.get(row.hub.id) ?? [] }))
416
483
  }
417
484
 
418
485
  /**
@@ -491,19 +558,212 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
491
558
  }
492
559
 
493
560
  /**
494
- * Retrieves the ID of the draft label.
495
- * 下書きラベルの ID を取得する(キャッシュ付き)。
561
+ * Retrieves every label id that counts as a draft.
562
+ * 下書きとみなすラベル ID を**すべて**取得する処理 (キャッシュ付き)。
563
+ *
564
+ * ❗ 可視性述語は必ずこちら (複数形) を使うこと。`draftLabelNames` には複数のラベル名を
565
+ * 注入できる (取込元ごとに下書きの呼び名が違うため) のに、1 個の ID だけで結合すると
566
+ * **2 つ目以降の下書きラベルが付いた日報が誰にでも可視になる**。
567
+ * 名前が 1 つも解決できない場合は空配列を返し、呼び出し側は結合対象を
568
+ * 「存在しない ID」にして「下書きは存在しない」と解釈する。
569
+ *
570
+ * @returns Draft label ids, ascending. 昇順の下書きラベル ID 群。
571
+ */
572
+ let cachedDraftLabelIds: number[] | null = null
573
+ const getDraftLabelIds = async (): Promise<number[]> => {
574
+ if (cachedDraftLabelIds !== null) return cachedDraftLabelIds
575
+ const rows = await db.select<{ id: number }>({ id: DailyReportLabel.id }).from(DailyReportLabel).where(inArray(DailyReportLabel.name, draftLabelNames)).orderBy(DailyReportLabel.id)
576
+ cachedDraftLabelIds = rows.map((row) => row.id)
577
+ return cachedDraftLabelIds
578
+ }
579
+
580
+ /**
581
+ * Join targets for the draft-label relation, safe when no draft label exists.
582
+ * 下書きラベル関連の結合対象。下書きラベルが 1 件も無い場合でも安全に働く値。
583
+ *
584
+ * `inArray` に空配列を渡すと方言によっては構文エラーや常に真になりうるため、
585
+ * 実在しない ID (`-1`) を 1 個入れて「どの行にも一致しない」ことを明示する。
586
+ *
587
+ * @returns Ids to join on. 結合に使う ID 群。
588
+ */
589
+ const getDraftLabelJoinIds = async (): Promise<number[]> => {
590
+ const ids = await getDraftLabelIds()
591
+ return ids.length > 0 ? ids : [-1]
592
+ }
593
+
594
+ /**
595
+ * Retrieves the canonical draft label id used when creating a draft.
596
+ * 下書きを作成するときに付与する代表の下書きラベル ID を取得する処理。
597
+ *
598
+ * 付与は 1 個に決めなければならないため `draftLabelNames` の先頭に対応する ID を返す。
599
+ * **可視性判定には使わないこと** (`getDraftLabelIds` を使う)。
600
+ *
601
+ * @returns The canonical draft label id, or null when it does not exist. 代表の下書きラベル ID (存在しなければ null)。
496
602
  */
497
- let cachedDraftLabelId: number | null = null
498
603
  const getDraftLabelId = async (): Promise<number | null> => {
499
- if (cachedDraftLabelId !== null) return cachedDraftLabelId
500
- const [label] = await db.select<{ id: number }>({ id: DailyReportLabel.id }).top(1).from(DailyReportLabel).where(inArray(DailyReportLabel.name, draftLabelNames))
604
+ const ids = await getDraftLabelIds()
605
+ if (ids.length === 0) return null
606
+ // 先頭の名前に対応する ID を選ぶ。ID 順ではなく注入順を正とする (代表名は draftLabelName)
607
+ const [primary] = await db.select<{ id: number }>({ id: DailyReportLabel.id }).top(1).from(DailyReportLabel).where(eq(DailyReportLabel.name, draftLabelName))
608
+ return primary?.id ?? ids[0]
609
+ }
610
+
611
+ /**
612
+ * Derives the tri-state verification status of an attachment's backing object.
613
+ * 添付ファイル実体の検証状態 (三値) を導出する処理。
614
+ *
615
+ * 判定不能 (`unknown`) を `absent` へ丸めない。存在確認そのものが失敗した場合を
616
+ * 偽の真偽で埋めると、一時的な権限・通信障害が「消失」として表示される。
617
+ */
618
+ const deriveAttachmentState = (checkedAt: Date | string | null, missingAt: Date | string | null): DailyReportAttachmentSummary["state"] => {
619
+ if (missingAt != null) return "absent"
620
+ if (checkedAt != null) return "present"
621
+ return "unknown"
622
+ }
501
623
 
502
- if (label) {
503
- cachedDraftLabelId = label.id
504
- return cachedDraftLabelId
624
+ /**
625
+ * Fetches attachments for multiple hubs in one query, keyed by hub id.
626
+ * 複数 hub の添付ファイルを 1 クエリで取得し hub ID をキーにまとめる処理。
627
+ *
628
+ * 一覧描画は N 件の日報を同時に返すため、日報ごとに問い合わせると N+1 になる。
629
+ * ラベル・コメントと同じ一括取得の流儀に揃える。
630
+ *
631
+ * @param hubIds Hub ids to fetch attachments for. 添付を取得する hub ID 群。
632
+ * @returns Attachments grouped by hub id. hub ID ごとにまとめた添付一覧。
633
+ */
634
+ const getAttachmentsByHubIds = async (hubIds: number[]): Promise<Map<number, DailyReportAttachmentSummary[]>> => {
635
+ const map = new Map<number, DailyReportAttachmentSummary[]>()
636
+ const attachment = tables.attachment
637
+ // codec は config 直参照。createDailyReportService の分割代入は 8 キーのみで、
638
+ // 裸の識別子として書くと TS2304 になる (任意設定は config.x で読むのがこのファイルの様式)
639
+ const codec = config.attachmentIdCodec
640
+ if (hubIds.length === 0 || !attachment || !codec) {
641
+ return map
642
+ }
643
+
644
+ const rows = await db
645
+ .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 }>({
646
+ id: attachment.id,
647
+ hubId: attachment.hubId,
648
+ fileName: attachment.fileName,
649
+ fileType: attachment.fileType,
650
+ fileSize: attachment.fileSize,
651
+ createdAt: attachment.createdAt,
652
+ objectCheckedAt: attachment.objectCheckedAt,
653
+ objectMissingAt: attachment.objectMissingAt,
654
+ })
655
+ .from(attachment)
656
+ .where(inArray(attachment.hubId, hubIds))
657
+ .orderBy(asc(attachment.createdAt), asc(attachment.id))
658
+
659
+ for (const row of rows) {
660
+ // 内部数値 ID はここで難読化トークンへ変換する。以降クライアントへ生 ID は出ない。
661
+ // `uploaded_by` / `created_by` / `updated_by` / `file_path` は選択もしない
662
+ const item: DailyReportAttachmentSummary = {
663
+ id: codec.encode(Number(row.id)),
664
+ fileName: String(row.fileName),
665
+ fileType: row.fileType ? String(row.fileType) : null,
666
+ fileSize: row.fileSize != null ? Number(row.fileSize) : null,
667
+ createdAt: formatDateValue(row.createdAt, "YYYY-MM-DD HH:mm:ss"),
668
+ state: deriveAttachmentState(row.objectCheckedAt, row.objectMissingAt),
669
+ }
670
+ const list = map.get(Number(row.hubId))
671
+ if (list) {
672
+ list.push(item)
673
+ } else {
674
+ map.set(Number(row.hubId), [item])
675
+ }
676
+ }
677
+
678
+ return map
679
+ }
680
+
681
+ /**
682
+ * Fetches one attachment together with its visibility check for a given user.
683
+ * 指定ユーザーの可視性判定込みで添付ファイル 1 件を取得する処理。
684
+ *
685
+ * 認可は SQL 側で完結させる。アプリ層のフィルタリングに依存しない。
686
+ * 認可されない / 存在しない / 親日報が論理削除済みのいずれでも `null` を返すため、
687
+ * 呼び出し側は 403 と 404 を区別せず 404 とすること (存在オラクル回避)。
688
+ *
689
+ * 本関数は意図的に `SqlResultCache.getOrFetch` を通さない。これは読み取りキャッシュではなく
690
+ * リクエストごとの**認可判定**であり、結果をキャッシュするとユーザーを跨いで再利用されうる。
691
+ *
692
+ * @param attachmentId Internal numeric attachment id. 内部数値の添付 ID。
693
+ * @param userId Internal numeric user id of the viewer. 閲覧者の内部数値ユーザー ID。
694
+ * @returns Attachment row when visible, otherwise null. 可視なら添付行、それ以外は null。
695
+ */
696
+ const getAttachmentForUser = async (attachmentId: number, userId: number): Promise<{ fileName: string; filePath: string; fileType: string | null; fileSize: number | null } | null> => {
697
+ const attachment = tables.attachment
698
+ if (!attachment) {
699
+ return null
700
+ }
701
+ const draftLabelJoinIds = await getDraftLabelJoinIds()
702
+ const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
703
+
704
+ // `.top(1)` は `.select()` の直後でなければならない。drizzle-mssql は top() を
705
+ // db.select() が返す MsSqlSelectBuilder にしか定義しておらず、.from() 以降のクラスには
706
+ // 存在しない。誤った順序は型 (構造型 SelectChain が全段階に top を宣言) でも
707
+ // 単体テスト (モックが全段階で top を返す) でも検出されず、本番で TypeError になる。
708
+ const rows = await db
709
+ .select<{ fileName: string; filePath: string; fileType: string | null; fileSize: number | null }>({
710
+ fileName: attachment.fileName,
711
+ filePath: attachment.filePath,
712
+ fileType: attachment.fileType,
713
+ fileSize: attachment.fileSize,
714
+ })
715
+ .top(1)
716
+ .from(attachment)
717
+ .innerJoin(DailyReportHub, eq(DailyReportHub.id, attachment.hubId))
718
+ .leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
719
+ .where(and(eq(attachment.id, attachmentId), isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId))))
720
+
721
+ return rows[0] ?? null
722
+ }
723
+
724
+ /**
725
+ * Records that an attachment's backing object was confirmed missing.
726
+ * 添付ファイルの実体が消失していたことを記録する処理。
727
+ *
728
+ * 実配信で `not_found` を得たときに呼ぶ。次回描画で三値が `absent` になる。
729
+ * 記録の失敗は配信結果に影響させない (呼び出し側は await しない)。
730
+ *
731
+ * @param attachmentId Internal numeric attachment id. 内部数値の添付 ID。
732
+ */
733
+ const markAttachmentMissing = async (attachmentId: number): Promise<void> => {
734
+ const attachment = tables.attachment
735
+ if (!attachment) return
736
+ const now = new Date()
737
+ try {
738
+ await db.update(attachment).set({ objectCheckedAt: now, objectMissingAt: now, updatedAt: now }).where(eq(attachment.id, attachmentId))
739
+ } catch (error) {
740
+ logger.error("markAttachmentMissing failed", error)
741
+ }
742
+ }
743
+
744
+ /**
745
+ * Records that an attachment's backing object was confirmed present.
746
+ * 添付ファイルの実体が存在していたことを記録する処理。
747
+ *
748
+ * `markAttachmentMissing` と対にすること。消失記録が一方向だと、一時的な障害で
749
+ * 一度 `absent` が立った添付は UI がリンクを描画しなくなり (押しても 404 なので当然)、
750
+ * **利用者が再取得を発火させる手段そのものが消える**。実体が戻っても手動の
751
+ * 一括検証を回すまで復帰しない。配信が 200 で成功した時点で解除するのが唯一の
752
+ * 自動回復経路である。
753
+ *
754
+ * 記録の失敗は配信結果に影響させない (呼び出し側は await しない)。
755
+ *
756
+ * @param attachmentId Internal numeric attachment id. 内部数値の添付 ID。
757
+ */
758
+ const markAttachmentPresent = async (attachmentId: number): Promise<void> => {
759
+ const attachment = tables.attachment
760
+ if (!attachment) return
761
+ const now = new Date()
762
+ try {
763
+ await db.update(attachment).set({ objectCheckedAt: now, objectMissingAt: null, updatedAt: now }).where(eq(attachment.id, attachmentId))
764
+ } catch (error) {
765
+ logger.error("markAttachmentPresent failed", error)
505
766
  }
506
- return null
507
767
  }
508
768
 
509
769
  /**
@@ -526,7 +786,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
526
786
  return sqlResultCache.getOrFetch<DailyReportDetail>({
527
787
  cacheKey,
528
788
  fetcher: async () => {
529
- const draftLabelId = await getDraftLabelId()
789
+ const draftLabelJoinIds = await getDraftLabelJoinIds()
530
790
  const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
531
791
 
532
792
  const rows = await applyExternalJoins(
@@ -543,7 +803,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
543
803
  )
544
804
  .leftJoin(DailyReportInternal, eq(DailyReportHub.id, DailyReportInternal.hubId))
545
805
  .leftJoin(DailyReportUserStatus, and(eq(DailyReportHub.id, DailyReportUserStatus.hubId), eq(DailyReportUserStatus.userId, userId)))
546
- .leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), eq(DraftLabelRelation.labelId, draftLabelId ?? -1)))
806
+ .leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
547
807
  .leftJoin(users, eq(DailyReportHub.userId, users.id))
548
808
  .where(and(eq(DailyReportHub.businessDate, sql`${normalizedBusinessDate}`), isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId))))
549
809
  .orderBy(desc(DailyReportHub.id))
@@ -580,6 +840,9 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
580
840
  .where(inArray(DailyReportCommentModel.hubId, hubIds))
581
841
  .orderBy(asc(DailyReportCommentModel.createdAt))
582
842
 
843
+ // 添付も同じ流儀で一括取得する (日報ごとに引くと N+1 になる)
844
+ const attachmentsMap = await getAttachmentsByHubIds(hubIds)
845
+
583
846
  const labelsMap = new Map<number, RawJsonLabel[]>()
584
847
  const commentsMap = new Map<number, RawJsonComment[]>()
585
848
 
@@ -616,6 +879,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
616
879
  return mapHubRecord(
617
880
  {
618
881
  ...row,
882
+ attachments: attachmentsMap.get(hubId) ?? [],
619
883
  labels: labelsMap.get(hubId) || [],
620
884
  comments: commentsMap.get(hubId) || [],
621
885
  },
@@ -633,6 +897,13 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
633
897
  /**
634
898
  * Retrieves a single daily report detail by ID with user-specific status and relations.
635
899
  * 指定した日報詳細を、ユーザー状態と関連データを含めて取得する。
900
+ *
901
+ * 可視性の述語は営業日一覧 (`getDailyReportsByBusinessDateByExternalId`) および
902
+ * `getAttachmentForUser` と同一に保つこと。3 経路のどれか 1 つだけ緩いと、
903
+ * 緩い経路がメタデータ (件名・本文・添付のファイル名やトークン) を渡してしまい、
904
+ * 「SQL 述語が唯一の認可境界」という不変条件が崩れる。
905
+ * 4 番目の経路である `fetchDailyReportIdsByUserId` だけは意図的に 1 つ緩い
906
+ * (返すのが ID・営業日・ソース種別のみのため)。詳細は同関数の docstring を参照。
636
907
  */
637
908
  const getDailyReportDetailById = async (reportHubId: number, userId: number, { forceRefresh = false, snapshot = false, ttlMsOverride }: SqlResultCacheQueryOptions = {}): Promise<DailyReportDetail | null> => {
638
909
  const cacheKey = `daily-report:detail:${reportHubId}:user:${userId}`
@@ -640,6 +911,9 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
640
911
  const results = (await sqlResultCache.getOrFetch<DailyReportDetail>({
641
912
  cacheKey,
642
913
  fetcher: async () => {
914
+ const draftLabelJoinIds = await getDraftLabelJoinIds()
915
+ const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
916
+
643
917
  const rows = await applyExternalJoins(
644
918
  db
645
919
  .select<HubQueryRow>({
@@ -654,8 +928,9 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
654
928
  )
655
929
  .leftJoin(DailyReportInternal, eq(DailyReportHub.id, DailyReportInternal.hubId))
656
930
  .leftJoin(DailyReportUserStatus, and(eq(DailyReportHub.id, DailyReportUserStatus.hubId), eq(DailyReportUserStatus.userId, userId)))
931
+ .leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
657
932
  .leftJoin(users, eq(DailyReportHub.userId, users.id))
658
- .where(and(eq(DailyReportHub.id, reportHubId), isNull(DailyReportHub.deletedAt)))
933
+ .where(and(eq(DailyReportHub.id, reportHubId), isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId))))
659
934
 
660
935
  if (rows.length === 0) {
661
936
  return []
@@ -687,6 +962,8 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
687
962
  .where(eq(DailyReportCommentModel.hubId, hubId))
688
963
  .orderBy(asc(DailyReportCommentModel.createdAt))
689
964
 
965
+ const attachmentsMap = await getAttachmentsByHubIds([hubId])
966
+
690
967
  const labelsMapped = labels.map((l) => ({
691
968
  id: l.id,
692
969
  name: l.name,
@@ -705,6 +982,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
705
982
  mapHubRecord(
706
983
  {
707
984
  ...rows[0],
985
+ attachments: attachmentsMap.get(hubId) ?? [],
708
986
  labels: labelsMapped,
709
987
  comments: commentsMapped,
710
988
  },
@@ -1072,6 +1350,8 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1072
1350
  isStarred: false,
1073
1351
  labels: labels,
1074
1352
  commentItems: [],
1353
+ // 新規作成直後の日報に添付は存在しない
1354
+ attachments: [],
1075
1355
  }
1076
1356
  })
1077
1357
 
@@ -1092,8 +1372,9 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1092
1372
 
1093
1373
  if (fullDetail) {
1094
1374
  // 下書き判定
1095
- const draftLabelId = await getDraftLabelId()
1096
- const isDraft = draftLabelId ? fullDetail.labels.some((l) => l.id === draftLabelId) : false
1375
+ // 判定は代表 ID ではなく下書きラベル**全体**で行う (2 つ目の下書き名を見落とさない)
1376
+ const draftLabelIds = await getDraftLabelIds()
1377
+ const isDraft = fullDetail.labels.some((l) => draftLabelIds.includes(l.id))
1097
1378
  await publishToSseStream(
1098
1379
  reportCreateMessageSchema.parse({
1099
1380
  type: "report-create",
@@ -1230,8 +1511,9 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1230
1511
  const fullDetail = await getDailyReportDetailById(reportHubId, userId, { forceRefresh: true })
1231
1512
  if (fullDetail) {
1232
1513
  // 下書き判定
1233
- const draftLabelId = await getDraftLabelId()
1234
- const isDraft = draftLabelId ? fullDetail.labels.some((l) => l.id === draftLabelId) : false
1514
+ // 判定は代表 ID ではなく下書きラベル**全体**で行う (2 つ目の下書き名を見落とさない)
1515
+ const draftLabelIds = await getDraftLabelIds()
1516
+ const isDraft = fullDetail.labels.some((l) => draftLabelIds.includes(l.id))
1235
1517
  await publishToSseStream(
1236
1518
  reportUpdateMessageSchema.parse({
1237
1519
  type: "report-update",
@@ -1298,14 +1580,48 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1298
1580
  return fullDetail ?? null
1299
1581
  }
1300
1582
 
1583
+ /**
1584
+ * Invalidates the read caches that carry hub-scoped data across every process.
1585
+ * hub 単位のデータを載せている読み取りキャッシュを、全プロセス横断で無効化する処理。
1586
+ *
1587
+ * 用途: 通常の更新経路 (create / update / publish) を通らずに DB を書き換えた場合、
1588
+ * 例えば外部システムからの一括取込。プロセス内 Map を消すだけでは稼働中の
1589
+ * 別プロセス (別ワーカー・別コンテナ) のキャッシュが TTL まで stale のままになるため、
1590
+ * Redis の epoch を進めて全プロセスのキャッシュ行を一斉に無効化する。
1591
+ *
1592
+ * `clearCache()` との違い: `clearCache()` が進める epoch は一覧 ID のものだけで、
1593
+ * 詳細キャッシュ (`detail-epoch:<hubId>`) と営業日キャッシュ (`date-epoch:<date>`) は
1594
+ * 進まない。日報本体に紐づくデータ (添付など) を書き換えたときはこちらを使うこと。
1595
+ *
1596
+ * @param hubIds Hub ids whose detail cache must be dropped. 詳細キャッシュを捨てる hub ID 群。
1597
+ * @param businessDates Business dates (YYYY-MM-DD) whose list cache must be dropped. 一覧キャッシュを捨てる営業日群。
1598
+ * @returns Nothing. なし。
1599
+ */
1600
+ const invalidateHubReadCaches = async (hubIds: readonly number[], businessDates: readonly string[]): Promise<void> => {
1601
+ sqlResultCache.invalidatePrefix(DAILY_REPORT_BUSINESS_DATE_CACHE_PREFIX)
1602
+ for (const hubId of new Set(hubIds)) {
1603
+ sqlResultCache.invalidatePrefix(`daily-report:detail:${hubId}:user:`)
1604
+ await incrementRedisEpoch(`${DAILY_REPORT_DETAIL_EPOCH_PREFIX}${hubId}`)
1605
+ }
1606
+ for (const businessDate of new Set(businessDates)) {
1607
+ const normalized = normalizeBusinessDateKey(businessDate)
1608
+ // 正規化できない値で epoch キーを作ると、誰も参照しないキーが増えるだけになる
1609
+ if (!normalized) continue
1610
+ await incrementRedisEpoch(`${DAILY_REPORT_DATE_EPOCH_PREFIX}${normalized}`)
1611
+ }
1612
+ }
1613
+
1301
1614
  /**
1302
1615
  * Clears all server-side SQL result and user ID caches.
1303
1616
  * サーバー側のすべての SQL 結果キャッシュおよびユーザー ID キャッシュを全消去する。
1617
+ *
1618
+ * Redis で進めるのは一覧 ID の epoch のみ。詳細/営業日キャッシュも捨てたい場合は
1619
+ * `invalidateHubReadCaches` を使うこと (このメソッドでは進まない)。
1304
1620
  */
1305
1621
  const clearCache = async (): Promise<void> => {
1306
1622
  sqlResultCache.clearAll()
1307
1623
  userIdCache.clear()
1308
- cachedDraftLabelId = null
1624
+ cachedDraftLabelIds = null
1309
1625
  if (epochs) {
1310
1626
  await incrementRedisEpoch(DAILY_REPORT_IDS_EPOCH_KEY)
1311
1627
  }
@@ -1318,15 +1634,22 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1318
1634
  streamMaxLen: DAILY_REPORT_SSE_STREAM_MAXLEN,
1319
1635
  // キャッシュクリア
1320
1636
  clearCache,
1637
+ invalidateHubReadCaches,
1321
1638
  // ユーザー解決
1322
1639
  getUserIdByExternalId,
1323
1640
  // 参照系
1324
1641
  getDailyReportIdsByExternalId,
1642
+ getDailyReportIdsFirstPageByExternalId,
1325
1643
  getDailyReportsByBusinessDate,
1326
1644
  getDailyReportsByBusinessDateByExternalId,
1327
1645
  getDailyReportDetailById,
1328
1646
  getDailyReportDetailByIdByExternalId,
1329
1647
  getDraftLabelId,
1648
+ // 添付 (ハンドラーが認可判定と実体消失記録に使う)
1649
+ getAttachmentsByHubIds,
1650
+ getAttachmentForUser,
1651
+ markAttachmentMissing,
1652
+ markAttachmentPresent,
1330
1653
  // 更新系
1331
1654
  setStarStatus,
1332
1655
  setReadStatus,