@aiquants/daily-report 0.6.2 → 0.7.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 (49) hide show
  1. package/dist/client.d.mts +81 -3
  2. package/dist/client.d.ts +81 -3
  3. package/dist/client.js +4 -4
  4. package/dist/client.js.map +1 -1
  5. package/dist/client.mjs +4 -4
  6. package/dist/client.mjs.map +1 -1
  7. package/dist/index.d.mts +2 -2
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.js +1 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.mjs +1 -1
  12. package/dist/index.mjs.map +1 -1
  13. package/dist/server.d.mts +434 -6
  14. package/dist/server.d.ts +434 -6
  15. package/dist/server.js +6 -6
  16. package/dist/server.js.map +1 -1
  17. package/dist/server.mjs +6 -6
  18. package/dist/server.mjs.map +1 -1
  19. package/dist/{sse-schema-rbG114od.d.mts → sse-schema-Df49KA7B.d.mts} +643 -249
  20. package/dist/{sse-schema-eXcMG-Ej.d.ts → sse-schema-RHckD7TS.d.ts} +643 -249
  21. package/dist/styles/daily-report.standalone.css +1 -1
  22. package/dist/{types-D1PKubyo.d.mts → types-Ct1ggzy-.d.mts} +30 -1
  23. package/dist/{types-D1PKubyo.d.ts → types-Ct1ggzy-.d.ts} +30 -1
  24. package/package.json +3 -3
  25. package/src/client/components/daily-report-attachment-indicator.spec.tsx +51 -0
  26. package/src/client/components/daily-report-attachment-indicator.tsx +45 -0
  27. package/src/client/components/daily-report-attachment-list.spec.tsx +94 -0
  28. package/src/client/components/daily-report-attachment-list.tsx +112 -0
  29. package/src/client/components/daily-report-detail-list.tsx +14 -7
  30. package/src/client/components/daily-report-list.tsx +19 -11
  31. package/src/client/components/report-views.spec.tsx +340 -0
  32. package/src/client/config-context.tsx +8 -0
  33. package/src/client/contexts/daily-report-action-context.tsx +2 -0
  34. package/src/client/hooks/use-responsive-layout.spec.ts +74 -0
  35. package/src/client/hooks/use-responsive-layout.ts +56 -0
  36. package/src/client/utils/constants.spec.ts +96 -0
  37. package/src/client/utils/constants.ts +47 -2
  38. package/src/client.ts +2 -0
  39. package/src/server/handlers.attachment.spec.ts +470 -0
  40. package/src/server/handlers.ts +426 -1
  41. package/src/server/ports.ts +74 -0
  42. package/src/server/schema.ts +80 -5
  43. package/src/server/service.spec.ts +280 -14
  44. package/src/server/service.ts +308 -25
  45. package/src/server/test-helpers/handlers-config.ts +142 -0
  46. package/src/server.ts +16 -1
  47. package/src/shared/sse-schema.spec.ts +50 -0
  48. package/src/shared/sse-schema.ts +27 -0
  49. 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,26 @@ 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()
377
404
 
378
405
  const result = await db
379
406
  .select<{ reportHubId: number; businessDate: Date | string | null; sourceType: string }>({
@@ -382,7 +409,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
382
409
  sourceType: DailyReportHub.sourceType,
383
410
  })
384
411
  .from(DailyReportHub)
385
- .leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), eq(DraftLabelRelation.labelId, draftLabelId ?? -1)))
412
+ .leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
386
413
  .where(and(isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DailyReportHub.userId), isNull(DraftLabelRelation.hubId))))
387
414
  .orderBy(desc(DailyReportHub.businessDate), desc(DailyReportHub.id))
388
415
 
@@ -412,7 +439,8 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
412
439
  .where(and(eq(DailyReportHub.businessDate, sql`${normalizedBusinessDate}`), isNull(DailyReportHub.deletedAt)))
413
440
  .orderBy(desc(DailyReportHub.id))
414
441
 
415
- return rows.map((row) => mapHubRecord(row))
442
+ const attachmentsMap = await getAttachmentsByHubIds(rows.map((r) => r.hub.id))
443
+ return rows.map((row) => mapHubRecord({ ...row, attachments: attachmentsMap.get(row.hub.id) ?? [] }))
416
444
  }
417
445
 
418
446
  /**
@@ -491,19 +519,212 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
491
519
  }
492
520
 
493
521
  /**
494
- * Retrieves the ID of the draft label.
495
- * 下書きラベルの ID を取得する(キャッシュ付き)。
522
+ * Retrieves every label id that counts as a draft.
523
+ * 下書きとみなすラベル ID を**すべて**取得する処理 (キャッシュ付き)。
524
+ *
525
+ * ❗ 可視性述語は必ずこちら (複数形) を使うこと。`draftLabelNames` には複数のラベル名を
526
+ * 注入できる (取込元ごとに下書きの呼び名が違うため) のに、1 個の ID だけで結合すると
527
+ * **2 つ目以降の下書きラベルが付いた日報が誰にでも可視になる**。
528
+ * 名前が 1 つも解決できない場合は空配列を返し、呼び出し側は結合対象を
529
+ * 「存在しない ID」にして「下書きは存在しない」と解釈する。
530
+ *
531
+ * @returns Draft label ids, ascending. 昇順の下書きラベル ID 群。
532
+ */
533
+ let cachedDraftLabelIds: number[] | null = null
534
+ const getDraftLabelIds = async (): Promise<number[]> => {
535
+ if (cachedDraftLabelIds !== null) return cachedDraftLabelIds
536
+ const rows = await db.select<{ id: number }>({ id: DailyReportLabel.id }).from(DailyReportLabel).where(inArray(DailyReportLabel.name, draftLabelNames)).orderBy(DailyReportLabel.id)
537
+ cachedDraftLabelIds = rows.map((row) => row.id)
538
+ return cachedDraftLabelIds
539
+ }
540
+
541
+ /**
542
+ * Join targets for the draft-label relation, safe when no draft label exists.
543
+ * 下書きラベル関連の結合対象。下書きラベルが 1 件も無い場合でも安全に働く値。
544
+ *
545
+ * `inArray` に空配列を渡すと方言によっては構文エラーや常に真になりうるため、
546
+ * 実在しない ID (`-1`) を 1 個入れて「どの行にも一致しない」ことを明示する。
547
+ *
548
+ * @returns Ids to join on. 結合に使う ID 群。
549
+ */
550
+ const getDraftLabelJoinIds = async (): Promise<number[]> => {
551
+ const ids = await getDraftLabelIds()
552
+ return ids.length > 0 ? ids : [-1]
553
+ }
554
+
555
+ /**
556
+ * Retrieves the canonical draft label id used when creating a draft.
557
+ * 下書きを作成するときに付与する代表の下書きラベル ID を取得する処理。
558
+ *
559
+ * 付与は 1 個に決めなければならないため `draftLabelNames` の先頭に対応する ID を返す。
560
+ * **可視性判定には使わないこと** (`getDraftLabelIds` を使う)。
561
+ *
562
+ * @returns The canonical draft label id, or null when it does not exist. 代表の下書きラベル ID (存在しなければ null)。
496
563
  */
497
- let cachedDraftLabelId: number | null = null
498
564
  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))
565
+ const ids = await getDraftLabelIds()
566
+ if (ids.length === 0) return null
567
+ // 先頭の名前に対応する ID を選ぶ。ID 順ではなく注入順を正とする (代表名は draftLabelName)
568
+ const [primary] = await db.select<{ id: number }>({ id: DailyReportLabel.id }).top(1).from(DailyReportLabel).where(eq(DailyReportLabel.name, draftLabelName))
569
+ return primary?.id ?? ids[0]
570
+ }
501
571
 
502
- if (label) {
503
- cachedDraftLabelId = label.id
504
- return cachedDraftLabelId
572
+ /**
573
+ * Derives the tri-state verification status of an attachment's backing object.
574
+ * 添付ファイル実体の検証状態 (三値) を導出する処理。
575
+ *
576
+ * 判定不能 (`unknown`) を `absent` へ丸めない。存在確認そのものが失敗した場合を
577
+ * 偽の真偽で埋めると、一時的な権限・通信障害が「消失」として表示される。
578
+ */
579
+ const deriveAttachmentState = (checkedAt: Date | string | null, missingAt: Date | string | null): DailyReportAttachmentSummary["state"] => {
580
+ if (missingAt != null) return "absent"
581
+ if (checkedAt != null) return "present"
582
+ return "unknown"
583
+ }
584
+
585
+ /**
586
+ * Fetches attachments for multiple hubs in one query, keyed by hub id.
587
+ * 複数 hub の添付ファイルを 1 クエリで取得し hub ID をキーにまとめる処理。
588
+ *
589
+ * 一覧描画は N 件の日報を同時に返すため、日報ごとに問い合わせると N+1 になる。
590
+ * ラベル・コメントと同じ一括取得の流儀に揃える。
591
+ *
592
+ * @param hubIds Hub ids to fetch attachments for. 添付を取得する hub ID 群。
593
+ * @returns Attachments grouped by hub id. hub ID ごとにまとめた添付一覧。
594
+ */
595
+ const getAttachmentsByHubIds = async (hubIds: number[]): Promise<Map<number, DailyReportAttachmentSummary[]>> => {
596
+ const map = new Map<number, DailyReportAttachmentSummary[]>()
597
+ const attachment = tables.attachment
598
+ // codec は config 直参照。createDailyReportService の分割代入は 8 キーのみで、
599
+ // 裸の識別子として書くと TS2304 になる (任意設定は config.x で読むのがこのファイルの様式)
600
+ const codec = config.attachmentIdCodec
601
+ if (hubIds.length === 0 || !attachment || !codec) {
602
+ return map
603
+ }
604
+
605
+ const rows = await db
606
+ .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 }>({
607
+ id: attachment.id,
608
+ hubId: attachment.hubId,
609
+ fileName: attachment.fileName,
610
+ fileType: attachment.fileType,
611
+ fileSize: attachment.fileSize,
612
+ createdAt: attachment.createdAt,
613
+ objectCheckedAt: attachment.objectCheckedAt,
614
+ objectMissingAt: attachment.objectMissingAt,
615
+ })
616
+ .from(attachment)
617
+ .where(inArray(attachment.hubId, hubIds))
618
+ .orderBy(asc(attachment.createdAt), asc(attachment.id))
619
+
620
+ for (const row of rows) {
621
+ // 内部数値 ID はここで難読化トークンへ変換する。以降クライアントへ生 ID は出ない。
622
+ // `uploaded_by` / `created_by` / `updated_by` / `file_path` は選択もしない
623
+ const item: DailyReportAttachmentSummary = {
624
+ id: codec.encode(Number(row.id)),
625
+ fileName: String(row.fileName),
626
+ fileType: row.fileType ? String(row.fileType) : null,
627
+ fileSize: row.fileSize != null ? Number(row.fileSize) : null,
628
+ createdAt: formatDateValue(row.createdAt, "YYYY-MM-DD HH:mm:ss"),
629
+ state: deriveAttachmentState(row.objectCheckedAt, row.objectMissingAt),
630
+ }
631
+ const list = map.get(Number(row.hubId))
632
+ if (list) {
633
+ list.push(item)
634
+ } else {
635
+ map.set(Number(row.hubId), [item])
636
+ }
637
+ }
638
+
639
+ return map
640
+ }
641
+
642
+ /**
643
+ * Fetches one attachment together with its visibility check for a given user.
644
+ * 指定ユーザーの可視性判定込みで添付ファイル 1 件を取得する処理。
645
+ *
646
+ * 認可は SQL 側で完結させる。アプリ層のフィルタリングに依存しない。
647
+ * 認可されない / 存在しない / 親日報が論理削除済みのいずれでも `null` を返すため、
648
+ * 呼び出し側は 403 と 404 を区別せず 404 とすること (存在オラクル回避)。
649
+ *
650
+ * 本関数は意図的に `SqlResultCache.getOrFetch` を通さない。これは読み取りキャッシュではなく
651
+ * リクエストごとの**認可判定**であり、結果をキャッシュするとユーザーを跨いで再利用されうる。
652
+ *
653
+ * @param attachmentId Internal numeric attachment id. 内部数値の添付 ID。
654
+ * @param userId Internal numeric user id of the viewer. 閲覧者の内部数値ユーザー ID。
655
+ * @returns Attachment row when visible, otherwise null. 可視なら添付行、それ以外は null。
656
+ */
657
+ const getAttachmentForUser = async (attachmentId: number, userId: number): Promise<{ fileName: string; filePath: string; fileType: string | null; fileSize: number | null } | null> => {
658
+ const attachment = tables.attachment
659
+ if (!attachment) {
660
+ return null
661
+ }
662
+ const draftLabelJoinIds = await getDraftLabelJoinIds()
663
+ const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
664
+
665
+ // `.top(1)` は `.select()` の直後でなければならない。drizzle-mssql は top() を
666
+ // db.select() が返す MsSqlSelectBuilder にしか定義しておらず、.from() 以降のクラスには
667
+ // 存在しない。誤った順序は型 (構造型 SelectChain が全段階に top を宣言) でも
668
+ // 単体テスト (モックが全段階で top を返す) でも検出されず、本番で TypeError になる。
669
+ const rows = await db
670
+ .select<{ fileName: string; filePath: string; fileType: string | null; fileSize: number | null }>({
671
+ fileName: attachment.fileName,
672
+ filePath: attachment.filePath,
673
+ fileType: attachment.fileType,
674
+ fileSize: attachment.fileSize,
675
+ })
676
+ .top(1)
677
+ .from(attachment)
678
+ .innerJoin(DailyReportHub, eq(DailyReportHub.id, attachment.hubId))
679
+ .leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
680
+ .where(and(eq(attachment.id, attachmentId), isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId))))
681
+
682
+ return rows[0] ?? null
683
+ }
684
+
685
+ /**
686
+ * Records that an attachment's backing object was confirmed missing.
687
+ * 添付ファイルの実体が消失していたことを記録する処理。
688
+ *
689
+ * 実配信で `not_found` を得たときに呼ぶ。次回描画で三値が `absent` になる。
690
+ * 記録の失敗は配信結果に影響させない (呼び出し側は await しない)。
691
+ *
692
+ * @param attachmentId Internal numeric attachment id. 内部数値の添付 ID。
693
+ */
694
+ const markAttachmentMissing = async (attachmentId: number): Promise<void> => {
695
+ const attachment = tables.attachment
696
+ if (!attachment) return
697
+ const now = new Date()
698
+ try {
699
+ await db.update(attachment).set({ objectCheckedAt: now, objectMissingAt: now, updatedAt: now }).where(eq(attachment.id, attachmentId))
700
+ } catch (error) {
701
+ logger.error("markAttachmentMissing failed", error)
702
+ }
703
+ }
704
+
705
+ /**
706
+ * Records that an attachment's backing object was confirmed present.
707
+ * 添付ファイルの実体が存在していたことを記録する処理。
708
+ *
709
+ * `markAttachmentMissing` と対にすること。消失記録が一方向だと、一時的な障害で
710
+ * 一度 `absent` が立った添付は UI がリンクを描画しなくなり (押しても 404 なので当然)、
711
+ * **利用者が再取得を発火させる手段そのものが消える**。実体が戻っても手動の
712
+ * 一括検証を回すまで復帰しない。配信が 200 で成功した時点で解除するのが唯一の
713
+ * 自動回復経路である。
714
+ *
715
+ * 記録の失敗は配信結果に影響させない (呼び出し側は await しない)。
716
+ *
717
+ * @param attachmentId Internal numeric attachment id. 内部数値の添付 ID。
718
+ */
719
+ const markAttachmentPresent = async (attachmentId: number): Promise<void> => {
720
+ const attachment = tables.attachment
721
+ if (!attachment) return
722
+ const now = new Date()
723
+ try {
724
+ await db.update(attachment).set({ objectCheckedAt: now, objectMissingAt: null, updatedAt: now }).where(eq(attachment.id, attachmentId))
725
+ } catch (error) {
726
+ logger.error("markAttachmentPresent failed", error)
505
727
  }
506
- return null
507
728
  }
508
729
 
509
730
  /**
@@ -526,7 +747,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
526
747
  return sqlResultCache.getOrFetch<DailyReportDetail>({
527
748
  cacheKey,
528
749
  fetcher: async () => {
529
- const draftLabelId = await getDraftLabelId()
750
+ const draftLabelJoinIds = await getDraftLabelJoinIds()
530
751
  const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
531
752
 
532
753
  const rows = await applyExternalJoins(
@@ -543,7 +764,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
543
764
  )
544
765
  .leftJoin(DailyReportInternal, eq(DailyReportHub.id, DailyReportInternal.hubId))
545
766
  .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)))
767
+ .leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
547
768
  .leftJoin(users, eq(DailyReportHub.userId, users.id))
548
769
  .where(and(eq(DailyReportHub.businessDate, sql`${normalizedBusinessDate}`), isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId))))
549
770
  .orderBy(desc(DailyReportHub.id))
@@ -580,6 +801,9 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
580
801
  .where(inArray(DailyReportCommentModel.hubId, hubIds))
581
802
  .orderBy(asc(DailyReportCommentModel.createdAt))
582
803
 
804
+ // 添付も同じ流儀で一括取得する (日報ごとに引くと N+1 になる)
805
+ const attachmentsMap = await getAttachmentsByHubIds(hubIds)
806
+
583
807
  const labelsMap = new Map<number, RawJsonLabel[]>()
584
808
  const commentsMap = new Map<number, RawJsonComment[]>()
585
809
 
@@ -616,6 +840,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
616
840
  return mapHubRecord(
617
841
  {
618
842
  ...row,
843
+ attachments: attachmentsMap.get(hubId) ?? [],
619
844
  labels: labelsMap.get(hubId) || [],
620
845
  comments: commentsMap.get(hubId) || [],
621
846
  },
@@ -633,6 +858,13 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
633
858
  /**
634
859
  * Retrieves a single daily report detail by ID with user-specific status and relations.
635
860
  * 指定した日報詳細を、ユーザー状態と関連データを含めて取得する。
861
+ *
862
+ * 可視性の述語は営業日一覧 (`getDailyReportsByBusinessDateByExternalId`) および
863
+ * `getAttachmentForUser` と同一に保つこと。3 経路のどれか 1 つだけ緩いと、
864
+ * 緩い経路がメタデータ (件名・本文・添付のファイル名やトークン) を渡してしまい、
865
+ * 「SQL 述語が唯一の認可境界」という不変条件が崩れる。
866
+ * 4 番目の経路である `fetchDailyReportIdsByUserId` だけは意図的に 1 つ緩い
867
+ * (返すのが ID・営業日・ソース種別のみのため)。詳細は同関数の docstring を参照。
636
868
  */
637
869
  const getDailyReportDetailById = async (reportHubId: number, userId: number, { forceRefresh = false, snapshot = false, ttlMsOverride }: SqlResultCacheQueryOptions = {}): Promise<DailyReportDetail | null> => {
638
870
  const cacheKey = `daily-report:detail:${reportHubId}:user:${userId}`
@@ -640,6 +872,9 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
640
872
  const results = (await sqlResultCache.getOrFetch<DailyReportDetail>({
641
873
  cacheKey,
642
874
  fetcher: async () => {
875
+ const draftLabelJoinIds = await getDraftLabelJoinIds()
876
+ const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
877
+
643
878
  const rows = await applyExternalJoins(
644
879
  db
645
880
  .select<HubQueryRow>({
@@ -654,8 +889,9 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
654
889
  )
655
890
  .leftJoin(DailyReportInternal, eq(DailyReportHub.id, DailyReportInternal.hubId))
656
891
  .leftJoin(DailyReportUserStatus, and(eq(DailyReportHub.id, DailyReportUserStatus.hubId), eq(DailyReportUserStatus.userId, userId)))
892
+ .leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
657
893
  .leftJoin(users, eq(DailyReportHub.userId, users.id))
658
- .where(and(eq(DailyReportHub.id, reportHubId), isNull(DailyReportHub.deletedAt)))
894
+ .where(and(eq(DailyReportHub.id, reportHubId), isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId))))
659
895
 
660
896
  if (rows.length === 0) {
661
897
  return []
@@ -687,6 +923,8 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
687
923
  .where(eq(DailyReportCommentModel.hubId, hubId))
688
924
  .orderBy(asc(DailyReportCommentModel.createdAt))
689
925
 
926
+ const attachmentsMap = await getAttachmentsByHubIds([hubId])
927
+
690
928
  const labelsMapped = labels.map((l) => ({
691
929
  id: l.id,
692
930
  name: l.name,
@@ -705,6 +943,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
705
943
  mapHubRecord(
706
944
  {
707
945
  ...rows[0],
946
+ attachments: attachmentsMap.get(hubId) ?? [],
708
947
  labels: labelsMapped,
709
948
  comments: commentsMapped,
710
949
  },
@@ -1072,6 +1311,8 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1072
1311
  isStarred: false,
1073
1312
  labels: labels,
1074
1313
  commentItems: [],
1314
+ // 新規作成直後の日報に添付は存在しない
1315
+ attachments: [],
1075
1316
  }
1076
1317
  })
1077
1318
 
@@ -1092,8 +1333,9 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1092
1333
 
1093
1334
  if (fullDetail) {
1094
1335
  // 下書き判定
1095
- const draftLabelId = await getDraftLabelId()
1096
- const isDraft = draftLabelId ? fullDetail.labels.some((l) => l.id === draftLabelId) : false
1336
+ // 判定は代表 ID ではなく下書きラベル**全体**で行う (2 つ目の下書き名を見落とさない)
1337
+ const draftLabelIds = await getDraftLabelIds()
1338
+ const isDraft = fullDetail.labels.some((l) => draftLabelIds.includes(l.id))
1097
1339
  await publishToSseStream(
1098
1340
  reportCreateMessageSchema.parse({
1099
1341
  type: "report-create",
@@ -1230,8 +1472,9 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1230
1472
  const fullDetail = await getDailyReportDetailById(reportHubId, userId, { forceRefresh: true })
1231
1473
  if (fullDetail) {
1232
1474
  // 下書き判定
1233
- const draftLabelId = await getDraftLabelId()
1234
- const isDraft = draftLabelId ? fullDetail.labels.some((l) => l.id === draftLabelId) : false
1475
+ // 判定は代表 ID ではなく下書きラベル**全体**で行う (2 つ目の下書き名を見落とさない)
1476
+ const draftLabelIds = await getDraftLabelIds()
1477
+ const isDraft = fullDetail.labels.some((l) => draftLabelIds.includes(l.id))
1235
1478
  await publishToSseStream(
1236
1479
  reportUpdateMessageSchema.parse({
1237
1480
  type: "report-update",
@@ -1298,14 +1541,48 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1298
1541
  return fullDetail ?? null
1299
1542
  }
1300
1543
 
1544
+ /**
1545
+ * Invalidates the read caches that carry hub-scoped data across every process.
1546
+ * hub 単位のデータを載せている読み取りキャッシュを、全プロセス横断で無効化する処理。
1547
+ *
1548
+ * 用途: 通常の更新経路 (create / update / publish) を通らずに DB を書き換えた場合、
1549
+ * 例えば外部システムからの一括取込。プロセス内 Map を消すだけでは稼働中の
1550
+ * 別プロセス (別ワーカー・別コンテナ) のキャッシュが TTL まで stale のままになるため、
1551
+ * Redis の epoch を進めて全プロセスのキャッシュ行を一斉に無効化する。
1552
+ *
1553
+ * `clearCache()` との違い: `clearCache()` が進める epoch は一覧 ID のものだけで、
1554
+ * 詳細キャッシュ (`detail-epoch:<hubId>`) と営業日キャッシュ (`date-epoch:<date>`) は
1555
+ * 進まない。日報本体に紐づくデータ (添付など) を書き換えたときはこちらを使うこと。
1556
+ *
1557
+ * @param hubIds Hub ids whose detail cache must be dropped. 詳細キャッシュを捨てる hub ID 群。
1558
+ * @param businessDates Business dates (YYYY-MM-DD) whose list cache must be dropped. 一覧キャッシュを捨てる営業日群。
1559
+ * @returns Nothing. なし。
1560
+ */
1561
+ const invalidateHubReadCaches = async (hubIds: readonly number[], businessDates: readonly string[]): Promise<void> => {
1562
+ sqlResultCache.invalidatePrefix(DAILY_REPORT_BUSINESS_DATE_CACHE_PREFIX)
1563
+ for (const hubId of new Set(hubIds)) {
1564
+ sqlResultCache.invalidatePrefix(`daily-report:detail:${hubId}:user:`)
1565
+ await incrementRedisEpoch(`${DAILY_REPORT_DETAIL_EPOCH_PREFIX}${hubId}`)
1566
+ }
1567
+ for (const businessDate of new Set(businessDates)) {
1568
+ const normalized = normalizeBusinessDateKey(businessDate)
1569
+ // 正規化できない値で epoch キーを作ると、誰も参照しないキーが増えるだけになる
1570
+ if (!normalized) continue
1571
+ await incrementRedisEpoch(`${DAILY_REPORT_DATE_EPOCH_PREFIX}${normalized}`)
1572
+ }
1573
+ }
1574
+
1301
1575
  /**
1302
1576
  * Clears all server-side SQL result and user ID caches.
1303
1577
  * サーバー側のすべての SQL 結果キャッシュおよびユーザー ID キャッシュを全消去する。
1578
+ *
1579
+ * Redis で進めるのは一覧 ID の epoch のみ。詳細/営業日キャッシュも捨てたい場合は
1580
+ * `invalidateHubReadCaches` を使うこと (このメソッドでは進まない)。
1304
1581
  */
1305
1582
  const clearCache = async (): Promise<void> => {
1306
1583
  sqlResultCache.clearAll()
1307
1584
  userIdCache.clear()
1308
- cachedDraftLabelId = null
1585
+ cachedDraftLabelIds = null
1309
1586
  if (epochs) {
1310
1587
  await incrementRedisEpoch(DAILY_REPORT_IDS_EPOCH_KEY)
1311
1588
  }
@@ -1318,6 +1595,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1318
1595
  streamMaxLen: DAILY_REPORT_SSE_STREAM_MAXLEN,
1319
1596
  // キャッシュクリア
1320
1597
  clearCache,
1598
+ invalidateHubReadCaches,
1321
1599
  // ユーザー解決
1322
1600
  getUserIdByExternalId,
1323
1601
  // 参照系
@@ -1327,6 +1605,11 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1327
1605
  getDailyReportDetailById,
1328
1606
  getDailyReportDetailByIdByExternalId,
1329
1607
  getDraftLabelId,
1608
+ // 添付 (ハンドラーが認可判定と実体消失記録に使う)
1609
+ getAttachmentsByHubIds,
1610
+ getAttachmentForUser,
1611
+ markAttachmentMissing,
1612
+ markAttachmentPresent,
1330
1613
  // 更新系
1331
1614
  setStarStatus,
1332
1615
  setReadStatus,
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Test fixture that assembles a minimal `DailyReportHandlersConfig`.
3
+ * 最小限の `DailyReportHandlersConfig` を組み立てるテスト用フィクスチャ。
4
+ *
5
+ * `handlers.ts` を対象にした spec がこれまで存在しなかったため、認証・サービス・
6
+ * 難読化・SSE リーダーのモック一式をここに集約する。各テストは必要な部分だけを上書きする。
7
+ *
8
+ * 本番コードからは import されない (テスト専用)。
9
+ */
10
+ import type { DailyReportHandlersConfig } from "../handlers"
11
+ import type { DailyReportAttachmentBytes, DailyReportAttachmentError, DailyReportIdCodec } from "../ports"
12
+ import type { DailyReportService } from "../service"
13
+ import type { DailyReportSseReader } from "../sse-reader"
14
+
15
+ /** 既定の外部ユーザー ID (認証済みユーザーを表す)。 */
16
+ export const TEST_EXTERNAL_USER_ID = "external-user"
17
+
18
+ /** 既定の内部ユーザー ID。 */
19
+ export const TEST_INTERNAL_USER_ID = 42
20
+
21
+ /** テスト用の添付 ID コーデック。`A<id>` 形式で、ユーザー ID 用とは別空間であることを表す。 */
22
+ export const testAttachmentCodec: DailyReportIdCodec = {
23
+ encode: (id) => `A${id}`,
24
+ decode: (token) => {
25
+ if (!token.startsWith("A")) return null
26
+ const value = Number(token.slice(1))
27
+ return Number.isInteger(value) ? value : null
28
+ },
29
+ }
30
+
31
+ /** `service.getAttachmentForUser` が返す行の形。 */
32
+ export type TestAttachmentRow = { fileName: string; filePath: string; fileType: string | null; fileSize: number | null }
33
+
34
+ /** フィクスチャの調整点。 */
35
+ export type HandlersFixtureOptions = {
36
+ /** 認証結果。null を渡すと未認証を表す。 */
37
+ user?: { id: string } | null
38
+ /** 認証ポートが返す `Set-Cookie`。 */
39
+ cookie?: string
40
+ /** 外部 ID → 内部 ID の解決結果。 */
41
+ internalUserId?: number | null
42
+ /** 認可判定の結果。null なら不可視 (404 相当)。 */
43
+ attachment?: TestAttachmentRow | null
44
+ /** 読み取りポートの応答。 */
45
+ readResult?: DailyReportAttachmentBytes | DailyReportAttachmentError
46
+ /** ポートを未注入にする (縮退動作の検証用)。 */
47
+ omitPorts?: boolean
48
+ /** 添付の上限バイト数。 */
49
+ attachmentMaxBytes?: number
50
+ /** レート上限 (1 分あたり)。 */
51
+ attachmentRateLimitPerMinute?: number
52
+ /** 同時実行上限。 */
53
+ attachmentConcurrency?: number
54
+ }
55
+
56
+ /** 呼び出し記録。テストが副作用を検証するために使う。 */
57
+ export type HandlersFixtureCalls = {
58
+ /** `service.getAttachmentForUser` が受け取った `(attachmentId, viewerUserId)`。 */
59
+ getAttachmentForUser: Array<[number, number]>
60
+ readAttachment: Array<{ filePath: string; maxBytes: number; head: boolean; principal?: string }>
61
+ markAttachmentMissing: number[]
62
+ markAttachmentPresent: number[]
63
+ }
64
+
65
+ /**
66
+ * Builds a handlers config plus the call log the assertions inspect.
67
+ * ハンドラー設定と、表明で参照する呼び出し記録を組み立てる処理。
68
+ *
69
+ * @param options Fixture adjustments. フィクスチャの調整点。
70
+ * @returns The config and its call log. 設定と呼び出し記録。
71
+ */
72
+ export const makeHandlersFixture = (options: HandlersFixtureOptions = {}): { config: DailyReportHandlersConfig; calls: HandlersFixtureCalls } => {
73
+ const calls: HandlersFixtureCalls = { getAttachmentForUser: [], readAttachment: [], markAttachmentMissing: [], markAttachmentPresent: [] }
74
+
75
+ const user = options.user === undefined ? { id: TEST_EXTERNAL_USER_ID } : options.user
76
+ const internalUserId = options.internalUserId === undefined ? TEST_INTERNAL_USER_ID : options.internalUserId
77
+ const attachment = options.attachment === undefined ? null : options.attachment
78
+ const readResult: DailyReportAttachmentBytes | DailyReportAttachmentError = options.readResult ?? { ok: true, bytes: new Uint8Array(new ArrayBuffer(3)), contentType: null }
79
+
80
+ const service = {
81
+ getUserIdByExternalId: async () => internalUserId,
82
+ getAttachmentForUser: async (attachmentId: number, viewerUserId: number) => {
83
+ // 引数を記録する。記録しないと「閲覧者 ID を渡し忘れた」欠陥がテストに映らない
84
+ calls.getAttachmentForUser.push([attachmentId, viewerUserId])
85
+ return attachment
86
+ },
87
+ markAttachmentMissing: async (id: number) => {
88
+ calls.markAttachmentMissing.push(id)
89
+ },
90
+ markAttachmentPresent: async (id: number) => {
91
+ calls.markAttachmentPresent.push(id)
92
+ },
93
+ } as unknown as DailyReportService
94
+
95
+ const base: DailyReportHandlersConfig = {
96
+ authenticate: async () => ({ user: user ?? undefined, cookie: options.cookie }),
97
+ service,
98
+ encodeUserId: (id) => `ENC${id}`,
99
+ sseReader: { subscribe: () => () => undefined } as unknown as DailyReportSseReader,
100
+ streamKey: "test-stream",
101
+ attachmentMaxBytes: options.attachmentMaxBytes,
102
+ attachmentRateLimitPerMinute: options.attachmentRateLimitPerMinute,
103
+ attachmentConcurrency: options.attachmentConcurrency,
104
+ }
105
+
106
+ if (options.omitPorts) {
107
+ return { config: base, calls }
108
+ }
109
+
110
+ return {
111
+ config: {
112
+ ...base,
113
+ attachmentIdCodec: testAttachmentCodec,
114
+ readAttachment: async (filePath, opts) => {
115
+ calls.readAttachment.push({ filePath, ...opts })
116
+ // 本物の実装は head:true のとき本体を読まず長さ 0 のビューを返し、実サイズは `size` で申告する。
117
+ // フィクスチャが常に本体長を返すと、HEAD の Content-Length が 0 になる欠陥が観測できない
118
+ if (opts.head && readResult.ok) {
119
+ return { ...readResult, bytes: new Uint8Array(new ArrayBuffer(0)), size: readResult.size ?? readResult.bytes.byteLength }
120
+ }
121
+ return readResult
122
+ },
123
+ },
124
+ calls,
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Builds loader args for the attachment endpoint.
130
+ * 添付エンドポイント用の loader 引数を組み立てる処理。
131
+ *
132
+ * @param token Attachment token in the URL. URL 中の添付トークン。
133
+ * @param init Optional method / query / headers. メソッド・クエリ・ヘッダーの任意指定。
134
+ * @returns Loader args. loader 引数。
135
+ */
136
+ export const makeAttachmentArgs = (token: string, init: { method?: string; query?: string; headers?: Record<string, string> } = {}) => ({
137
+ request: new Request(`http://localhost/daily_report/api/attachment/${encodeURIComponent(token)}${init.query ?? ""}`, {
138
+ method: init.method ?? "GET",
139
+ headers: init.headers,
140
+ }),
141
+ params: { token } as Record<string, string | undefined>,
142
+ })