@aiquants/daily-report 0.12.3 → 0.13.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 (39) hide show
  1. package/README.md +32 -0
  2. package/dist/client.d.mts +1 -1
  3. package/dist/client.d.ts +1 -1
  4. package/dist/client.js +5 -5
  5. package/dist/client.js.map +1 -1
  6. package/dist/client.mjs +5 -5
  7. package/dist/client.mjs.map +1 -1
  8. package/dist/index.d.mts +1 -1
  9. package/dist/index.d.ts +1 -1
  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 +175 -21
  15. package/dist/server.d.ts +175 -21
  16. package/dist/server.js +12 -9
  17. package/dist/server.js.map +1 -1
  18. package/dist/server.mjs +12 -9
  19. package/dist/server.mjs.map +1 -1
  20. package/dist/{sse-schema-YZh7Pz7i.d.ts → sse-schema-D1oZnFg6.d.ts} +18 -0
  21. package/dist/{sse-schema-BiUnufrI.d.mts → sse-schema-lMo8yW4e.d.mts} +18 -0
  22. package/package.json +1 -1
  23. package/src/client/components/daily-report-attachment-indicator.tsx +1 -1
  24. package/src/client/components/daily-report-detail-list.tsx +3 -3
  25. package/src/client/components/daily-report-ids-stream-status.tsx +1 -1
  26. package/src/client/components/daily-report-list.tsx +3 -3
  27. package/src/client/components/daily-report-resolved-content.tsx +3 -3
  28. package/src/lucide-icons.d.ts +3 -3
  29. package/src/server/handlers.ts +181 -35
  30. package/src/server/handlers.visibility.spec.ts +458 -0
  31. package/src/server/ports.ts +48 -0
  32. package/src/server/service.spec.ts +335 -14
  33. package/src/server/service.ts +222 -37
  34. package/src/server/test-helpers/handlers-config.ts +93 -9
  35. package/src/server/visibility.spec.ts +142 -0
  36. package/src/server/visibility.ts +189 -0
  37. package/src/server.spec.ts +72 -0
  38. package/src/server.ts +20 -1
  39. package/src/shared/sse-schema.ts +15 -0
@@ -13,6 +13,7 @@ import type { EpochStore, SqlResultCache, SqlResultCacheQueryOptions } from "./c
13
13
  import type { DailyReportExternalSource } from "./external-source"
14
14
  import type { DailyReportEncodeUserId, DailyReportIdCodec, DailyReportRedisProvider, DailyReportResolveUserId } from "./ports"
15
15
  import type { DailyReportTables, DailyReportUserTable } from "./schema"
16
+ import { buildVisibleSourceTypeCondition, computeSourceTypeDigest, deniesEverySourceType, isSourceTypeVisible, type VisibleSourceTypeSet } from "./visibility"
16
17
 
17
18
  export type { SqlResultCacheQueryOptions }
18
19
 
@@ -235,6 +236,32 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
235
236
 
236
237
  const incrementRedisEpoch = (key: string) => epochs.incrementEpoch(key)
237
238
 
239
+ /**
240
+ * Binds the shared visibility condition builder to this service's hub table.
241
+ * 共有の可視性条件ビルダーを本サービスの hub テーブルへ束縛したもの。
242
+ *
243
+ * 三値の意味と fail-close の方向は {@link buildVisibleSourceTypeCondition} が唯一の真実源。
244
+ */
245
+ const visibleSourceTypeCondition = (visible: VisibleSourceTypeSet) => buildVisibleSourceTypeCondition(DailyReportHub.sourceType, visible)
246
+
247
+ // ---- キャッシュキー生成 (ダイジェスト折り込みと無効化接頭辞の単一の真実源) ----
248
+ // ❗ ユーザー別キャッシュキーは必ずここから作る。可視集合ダイジェストの付け忘れ (= 別権限へ
249
+ // 古い結果を配信) と、無効化時の取りこぼしを構造的に防ぐ。接頭辞の末尾 ":" は
250
+ // ユーザー ID の前方一致衝突 (1 と 10) を避けるため必須。
251
+
252
+ /** ID 一覧キャッシュのキー (ユーザー + 可視集合ダイジェスト)。 */
253
+ const idsCacheKey = (userId: number, digest: string): string => `${DAILY_REPORT_IDS_CACHE_KEY}:user:${userId}:st:${digest}`
254
+ /** ID 一覧キャッシュの当該ユーザー全ダイジェスト分の接頭辞。 */
255
+ const idsUserPrefix = (userId: number): string => `${DAILY_REPORT_IDS_CACHE_KEY}:user:${userId}:`
256
+ /** 営業日別キャッシュのキー (営業日 + ユーザー + 可視集合ダイジェスト)。 */
257
+ const dateCacheKey = (normalizedBusinessDate: string, userId: number, digest: string): string => `daily-report:date:${normalizedBusinessDate}:user:${userId}:st:${digest}`
258
+ /** 営業日別キャッシュの当該ユーザー全ダイジェスト分の接頭辞。 */
259
+ const dateUserPrefix = (normalizedBusinessDate: string, userId: number): string => `daily-report:date:${normalizedBusinessDate}:user:${userId}:`
260
+ /** 詳細キャッシュのキー (hub + ユーザー + 可視集合ダイジェスト)。 */
261
+ const detailCacheKey = (reportHubId: number, userId: number, digest: string): string => `daily-report:detail:${reportHubId}:user:${userId}:st:${digest}`
262
+ /** 詳細キャッシュの当該ユーザー全ダイジェスト分の接頭辞。 */
263
+ const detailUserPrefix = (reportHubId: number, userId: number): string => `daily-report:detail:${reportHubId}:user:${userId}:`
264
+
238
265
  /**
239
266
  * Publishes a message to the SSE Redis Stream.
240
267
  * SSE 用の Redis Stream にメッセージを追加する。
@@ -420,8 +447,14 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
420
447
  * 本文も添付も含まないため、緩めても内容は漏れない。
421
448
  * この差分は無自覚なものではなく、`service.spec.ts` が明示的に表明して固定している。
422
449
  * 他の 3 経路をこの形に合わせてはならない (合わせると内容が漏れる)。
450
+ *
451
+ * 区分 (sourceType) の可視性は緩和の対象外であり、他経路と完全に同一の条件を適用する。
452
+ *
453
+ * @param userId Internal numeric user id. 内部数値ユーザー ID。
454
+ * @param visible Normalized visible source-type set. 正規化済みの可視 sourceType 集合。
455
+ * @returns Stream-ordered items. ストリーム順のアイテム一覧。
423
456
  */
424
- const fetchDailyReportIdsByUserId = async (userId: number): Promise<DailyReportItem[]> => {
457
+ const fetchDailyReportIdsByUserId = async (userId: number, visible: VisibleSourceTypeSet): Promise<DailyReportItem[]> => {
425
458
  const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
426
459
  const draftLabelJoinIds = await getDraftLabelJoinIds()
427
460
 
@@ -433,7 +466,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
433
466
  })
434
467
  .from(DailyReportHub)
435
468
  .leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
436
- .where(and(isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DailyReportHub.userId), isNull(DraftLabelRelation.hubId))))
469
+ .where(and(isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DailyReportHub.userId), isNull(DraftLabelRelation.hubId)), visibleSourceTypeCondition(visible)))
437
470
  .orderBy(desc(DailyReportHub.businessDate), desc(DailyReportHub.id))
438
471
 
439
472
  return result.map((item) => ({
@@ -453,13 +486,18 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
453
486
  *
454
487
  * @param externalId External user id. 外部ユーザー ID。
455
488
  * @param limit Page size. ページサイズ。
489
+ * @param visible Normalized visible source-type set. 正規化済みの可視 sourceType 集合。
456
490
  * @returns First-page items in stream order. ストリーム順の先頭ページ。
457
491
  */
458
- const getDailyReportIdsFirstPageByExternalId = async (externalId: string, limit: number): Promise<DailyReportItem[]> => {
492
+ const getDailyReportIdsFirstPageByExternalId = async (externalId: string, limit: number, visible: VisibleSourceTypeSet): Promise<DailyReportItem[]> => {
459
493
  const userId = await getUserIdByExternalId(externalId)
460
494
  if (!userId) {
461
495
  return []
462
496
  }
497
+ // 全拒否なら DB を引かずに空を返す (0 件 = fail-close)
498
+ if (deniesEverySourceType(visible)) {
499
+ return []
500
+ }
463
501
  const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
464
502
  const draftLabelJoinIds = await getDraftLabelJoinIds()
465
503
 
@@ -472,7 +510,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
472
510
  .top(limit)
473
511
  .from(DailyReportHub)
474
512
  .leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
475
- .where(and(isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DailyReportHub.userId), isNull(DraftLabelRelation.hubId))))
513
+ .where(and(isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DailyReportHub.userId), isNull(DraftLabelRelation.hubId)), visibleSourceTypeCondition(visible)))
476
514
  .orderBy(desc(DailyReportHub.businessDate), desc(DailyReportHub.id))
477
515
 
478
516
  return result.map((item) => ({
@@ -534,18 +572,28 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
534
572
  /**
535
573
  * Retrieves daily report IDs and their normalized business dates.
536
574
  * 日報 ID と正規化済み営業日を取得する処理。
575
+ *
576
+ * @param externalId External user id. 外部ユーザー ID。
577
+ * @param options Cache query options. キャッシュ問い合わせオプション。
578
+ * @param visible Normalized visible source-type set. 正規化済みの可視 sourceType 集合。
579
+ * @returns Stream-ordered items. ストリーム順のアイテム一覧。
537
580
  */
538
- const getDailyReportIdsByExternalId = async (externalId: string, { forceRefresh = false, snapshot = false, ttlMsOverride }: SqlResultCacheQueryOptions = {}): Promise<DailyReportItem[]> => {
581
+ const getDailyReportIdsByExternalId = async (externalId: string, { forceRefresh = false, snapshot = false, ttlMsOverride }: SqlResultCacheQueryOptions = {}, visible: VisibleSourceTypeSet): Promise<DailyReportItem[]> => {
539
582
  const userId = await getUserIdByExternalId(externalId)
540
583
  if (!userId) {
541
584
  return []
542
585
  }
586
+ // 全拒否なら DB を引かずに空を返す (0 件 = fail-close)
587
+ if (deniesEverySourceType(visible)) {
588
+ return []
589
+ }
543
590
 
544
- const cacheKey = `${DAILY_REPORT_IDS_CACHE_KEY}:user:${userId}`
591
+ // 可視集合ごとに別バケットへ隔離する (権限変更後に旧結果を配信しない)
592
+ const cacheKey = idsCacheKey(userId, computeSourceTypeDigest(visible))
545
593
  return sqlResultCache.getOrFetch<DailyReportItem>({
546
594
  cacheKey,
547
595
  fetcher: async () => {
548
- const res = await fetchDailyReportIdsByUserId(userId)
596
+ const res = await fetchDailyReportIdsByUserId(userId, visible)
549
597
  return res
550
598
  },
551
599
  forceRefresh,
@@ -717,13 +765,18 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
717
765
  *
718
766
  * @param attachmentId Internal numeric attachment id. 内部数値の添付 ID。
719
767
  * @param userId Internal numeric user id of the viewer. 閲覧者の内部数値ユーザー ID。
768
+ * @param visible Normalized visible source-type set. 正規化済みの可視 sourceType 集合。
720
769
  * @returns Attachment row when visible, otherwise null. 可視なら添付行、それ以外は null。
721
770
  */
722
- const getAttachmentForUser = async (attachmentId: number, userId: number): Promise<{ fileName: string; filePath: string; fileType: string | null; fileSize: number | null } | null> => {
771
+ const getAttachmentForUser = async (attachmentId: number, userId: number, visible: VisibleSourceTypeSet): Promise<{ fileName: string; filePath: string; fileType: string | null; fileSize: number | null } | null> => {
723
772
  const attachment = tables.attachment
724
773
  if (!attachment) {
725
774
  return null
726
775
  }
776
+ // 全拒否なら DB を引かずに null を返す (0 件 = fail-close)
777
+ if (deniesEverySourceType(visible)) {
778
+ return null
779
+ }
727
780
  const draftLabelJoinIds = await getDraftLabelJoinIds()
728
781
  const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
729
782
 
@@ -742,7 +795,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
742
795
  .from(attachment)
743
796
  .innerJoin(DailyReportHub, eq(DailyReportHub.id, attachment.hubId))
744
797
  .leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
745
- .where(and(eq(attachment.id, attachmentId), isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId))))
798
+ .where(and(eq(attachment.id, attachmentId), isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId)), visibleSourceTypeCondition(visible)))
746
799
 
747
800
  return rows[0] ?? null
748
801
  }
@@ -796,7 +849,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
796
849
  * Retrieves daily report details for a business date with user-specific status and relations.
797
850
  * 指定した営業日の日報詳細を、ユーザー状態と関連データを含めて取得する。
798
851
  */
799
- const getDailyReportsByBusinessDateByExternalId = async (businessDate: string, externalId: string, { forceRefresh = false, snapshot = false, ttlMsOverride }: SqlResultCacheQueryOptions = {}): Promise<DailyReportDetail[]> => {
852
+ const getDailyReportsByBusinessDateByExternalId = async (businessDate: string, externalId: string, { forceRefresh = false, snapshot = false, ttlMsOverride }: SqlResultCacheQueryOptions = {}, visible: VisibleSourceTypeSet): Promise<DailyReportDetail[]> => {
800
853
  const userId = await getUserIdByExternalId(externalId)
801
854
  if (!userId) {
802
855
  return []
@@ -806,8 +859,13 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
806
859
  if (!normalizedBusinessDate) {
807
860
  return []
808
861
  }
862
+ // 全拒否なら DB を引かずに空を返す (0 件 = fail-close)
863
+ if (deniesEverySourceType(visible)) {
864
+ return []
865
+ }
809
866
 
810
- const cacheKey = `daily-report:date:${normalizedBusinessDate}:user:${userId}`
867
+ // 可視集合ごとに別バケットへ隔離する (権限変更後に旧結果を配信しない)
868
+ const cacheKey = dateCacheKey(normalizedBusinessDate, userId, computeSourceTypeDigest(visible))
811
869
 
812
870
  return sqlResultCache.getOrFetch<DailyReportDetail>({
813
871
  cacheKey,
@@ -831,7 +889,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
831
889
  .leftJoin(DailyReportUserStatus, and(eq(DailyReportHub.id, DailyReportUserStatus.hubId), eq(DailyReportUserStatus.userId, userId)))
832
890
  .leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
833
891
  .leftJoin(users, eq(DailyReportHub.userId, users.id))
834
- .where(and(eq(DailyReportHub.businessDate, sql`${normalizedBusinessDate}`), isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId))))
892
+ .where(and(eq(DailyReportHub.businessDate, sql`${normalizedBusinessDate}`), isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId)), visibleSourceTypeCondition(visible)))
835
893
  .orderBy(desc(DailyReportHub.id))
836
894
 
837
895
  if (rows.length === 0) {
@@ -930,9 +988,21 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
930
988
  * 「SQL 述語が唯一の認可境界」という不変条件が崩れる。
931
989
  * 4 番目の経路である `fetchDailyReportIdsByUserId` だけは意図的に 1 つ緩い
932
990
  * (返すのが ID・営業日・ソース種別のみのため)。詳細は同関数の docstring を参照。
991
+ *
992
+ * @param reportHubId Hub id. 対象 hub の ID。
993
+ * @param userId Internal numeric user id of the viewer. 閲覧者の内部数値ユーザー ID。
994
+ * @param options Cache query options. キャッシュ問い合わせオプション。
995
+ * @param visible Normalized visible source-type set. 正規化済みの可視 sourceType 集合。
996
+ * @returns Detail when visible, otherwise null. 可視なら詳細、そうでなければ null。
933
997
  */
934
- const getDailyReportDetailById = async (reportHubId: number, userId: number, { forceRefresh = false, snapshot = false, ttlMsOverride }: SqlResultCacheQueryOptions = {}): Promise<DailyReportDetail | null> => {
935
- const cacheKey = `daily-report:detail:${reportHubId}:user:${userId}`
998
+ const getDailyReportDetailById = async (reportHubId: number, userId: number, { forceRefresh = false, snapshot = false, ttlMsOverride }: SqlResultCacheQueryOptions = {}, visible: VisibleSourceTypeSet): Promise<DailyReportDetail | null> => {
999
+ // 全拒否なら DB を引かずに null を返す (0 件 = fail-close)
1000
+ if (deniesEverySourceType(visible)) {
1001
+ return null
1002
+ }
1003
+
1004
+ // 可視集合ごとに別バケットへ隔離する (権限変更後に旧結果を配信しない)
1005
+ const cacheKey = detailCacheKey(reportHubId, userId, computeSourceTypeDigest(visible))
936
1006
 
937
1007
  const results = (await sqlResultCache.getOrFetch<DailyReportDetail>({
938
1008
  cacheKey,
@@ -956,7 +1026,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
956
1026
  .leftJoin(DailyReportUserStatus, and(eq(DailyReportHub.id, DailyReportUserStatus.hubId), eq(DailyReportUserStatus.userId, userId)))
957
1027
  .leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
958
1028
  .leftJoin(users, eq(DailyReportHub.userId, users.id))
959
- .where(and(eq(DailyReportHub.id, reportHubId), isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId))))
1029
+ .where(and(eq(DailyReportHub.id, reportHubId), isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId)), visibleSourceTypeCondition(visible)))
960
1030
 
961
1031
  if (rows.length === 0) {
962
1032
  return []
@@ -1028,20 +1098,69 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1028
1098
  /**
1029
1099
  * Retrieves a single daily report detail by ID with user-specific status and relations.
1030
1100
  * 指定した日報詳細を、ユーザー状態と関連データを含めて取得する。
1101
+ *
1102
+ * @param reportHubId Hub id. 対象 hub の ID。
1103
+ * @param externalId External user id. 外部ユーザー ID。
1104
+ * @param options Cache query options. キャッシュ問い合わせオプション。
1105
+ * @param visible Normalized visible source-type set. 正規化済みの可視 sourceType 集合。
1106
+ * @returns Detail when visible, otherwise null. 可視なら詳細、そうでなければ null。
1031
1107
  */
1032
- const getDailyReportDetailByIdByExternalId = async (reportHubId: number, externalId: string, options: SqlResultCacheQueryOptions = {}): Promise<DailyReportDetail | null> => {
1108
+ const getDailyReportDetailByIdByExternalId = async (reportHubId: number, externalId: string, options: SqlResultCacheQueryOptions = {}, visible: VisibleSourceTypeSet): Promise<DailyReportDetail | null> => {
1033
1109
  const userId = await getUserIdByExternalId(externalId)
1034
1110
  if (!userId) {
1035
1111
  return null
1036
1112
  }
1037
- return getDailyReportDetailById(reportHubId, userId, options)
1113
+ // ❗ 受け取った可視集合はそのまま透過する。ここで null へ潰すと外部 ID 経由の詳細取得だけ
1114
+ // フィルタが外れる (内部の create/update/publish が明示 null を渡すのとは別物)
1115
+ return getDailyReportDetailById(reportHubId, userId, options, visible)
1116
+ }
1117
+
1118
+ /**
1119
+ * Loads the gate columns of a hub for a mutation, or null when it does not exist.
1120
+ * ミューテーション用に hub のゲート列を取得する処理 (存在しなければ null)。
1121
+ *
1122
+ * @param reportHubId Hub id. 対象 hub の ID。
1123
+ * @returns Source type and deletion marker, or null. ソース種別と論理削除印 (無ければ null)。
1124
+ */
1125
+ const findHubGateColumns = async (reportHubId: number): Promise<{ sourceType: string; deletedAt: Date | string | null } | null> => {
1126
+ const [hub] = await db.select<{ sourceType: string; deletedAt: Date | string | null }>({ sourceType: DailyReportHub.sourceType, deletedAt: DailyReportHub.deletedAt }).top(1).from(DailyReportHub).where(eq(DailyReportHub.id, reportHubId))
1127
+ return hub ?? null
1128
+ }
1129
+
1130
+ /**
1131
+ * Rejects a status mutation targeting a missing, deleted, or invisible report.
1132
+ * 不存在・論理削除済み・不可視区分の日報に対する状態更新を拒否する処理。
1133
+ *
1134
+ * 認可失敗と不存在を区別せず一律 `Not Found` にする (存在オラクルの回避)。従来は hub を
1135
+ * 一切検証せず任意の `reportHubId` へ upsert できたため、その穴も同時に塞ぐ。
1136
+ *
1137
+ * @param reportHubId Hub id. 対象 hub の ID。
1138
+ * @param visible Normalized visible source-type set. 正規化済みの可視 sourceType 集合。
1139
+ * @returns Nothing; throws when rejected. なし (拒否時は例外送出)。
1140
+ */
1141
+ const assertHubMutable = async (reportHubId: number, visible: VisibleSourceTypeSet): Promise<void> => {
1142
+ const hub = await findHubGateColumns(reportHubId)
1143
+ if (!hub || hub.deletedAt || !isSourceTypeVisible(visible, hub.sourceType)) {
1144
+ throw new Error("Not Found")
1145
+ }
1038
1146
  }
1039
1147
 
1040
1148
  /**
1041
1149
  * Sets the starred status of a daily report for a user.
1042
1150
  * ユーザーの日報スター状態を設定する。
1151
+ *
1152
+ * @param userId Internal numeric user id. 内部数値ユーザー ID。
1153
+ * @param reportHubId Hub id. 対象 hub の ID。
1154
+ * @param businessDate Business date for cache invalidation. キャッシュ無効化に使う営業日。
1155
+ * @param isStarred Desired star state. 設定するスター状態。
1156
+ * @param clientTempId Optimistic-update correlation id. 楽観更新の相関 ID。
1157
+ * @param visible Normalized visible source-type set. 正規化済みの可視 sourceType 集合。
1158
+ * @returns Updated status row. 更新後の状態行。
1043
1159
  */
1044
- const setStarStatus = async (userId: number, reportHubId: number, businessDate: string | null, isStarred: boolean, clientTempId: string): Promise<DailyReportUserStatusRow> => {
1160
+ const setStarStatus = async (userId: number, reportHubId: number, businessDate: string | null, isStarred: boolean, clientTempId: string, visible: VisibleSourceTypeSet): Promise<DailyReportUserStatusRow> => {
1161
+ // 不可視区分・不存在・削除済みへの書き込みを先に弾く
1162
+ await assertHubMutable(reportHubId, visible)
1163
+
1045
1164
  const existing = await db
1046
1165
  .select<DailyReportUserStatusRow>()
1047
1166
  .top(1)
@@ -1085,12 +1204,14 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1085
1204
  if (businessDate) {
1086
1205
  const normalizedDate = normalizeBusinessDateKey(businessDate)
1087
1206
  if (normalizedDate) {
1088
- sqlResultCache.invalidate(`daily-report:date:${normalizedDate}:user:${userId}`)
1207
+ // 可視集合ダイジェスト付きキーを取りこぼさないよう接頭辞で消す
1208
+ sqlResultCache.invalidatePrefix(dateUserPrefix(normalizedDate, userId))
1089
1209
  await incrementRedisEpoch(`${DAILY_REPORT_DATE_EPOCH_PREFIX}${normalizedDate}`)
1090
1210
  }
1091
1211
  }
1092
1212
  sqlResultCache.invalidate(`daily-report:detail:${reportHubId}`)
1093
- sqlResultCache.invalidate(`daily-report:detail:${reportHubId}:user:${userId}`)
1213
+ // 可視集合ダイジェスト付きキーを取りこぼさないよう接頭辞で消す
1214
+ sqlResultCache.invalidatePrefix(detailUserPrefix(reportHubId, userId))
1094
1215
  await incrementRedisEpoch(`${DAILY_REPORT_DETAIL_EPOCH_PREFIX}${reportHubId}`)
1095
1216
 
1096
1217
  await publishToSseStream(
@@ -1111,8 +1232,19 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1111
1232
  /**
1112
1233
  * Sets the read status of a daily report for a user.
1113
1234
  * ユーザーの日報既読状態を設定する。
1235
+ *
1236
+ * @param userId Internal numeric user id. 内部数値ユーザー ID。
1237
+ * @param reportHubId Hub id. 対象 hub の ID。
1238
+ * @param businessDate Business date for cache invalidation. キャッシュ無効化に使う営業日。
1239
+ * @param isRead Desired read state. 設定する既読状態。
1240
+ * @param clientTempId Optimistic-update correlation id. 楽観更新の相関 ID。
1241
+ * @param visible Normalized visible source-type set. 正規化済みの可視 sourceType 集合。
1242
+ * @returns Updated status row. 更新後の状態行。
1114
1243
  */
1115
- const setReadStatus = async (userId: number, reportHubId: number, businessDate: string | null, isRead: boolean, clientTempId: string): Promise<DailyReportUserStatusRow> => {
1244
+ const setReadStatus = async (userId: number, reportHubId: number, businessDate: string | null, isRead: boolean, clientTempId: string, visible: VisibleSourceTypeSet): Promise<DailyReportUserStatusRow> => {
1245
+ // 不可視区分・不存在・削除済みへの書き込みを先に弾く
1246
+ await assertHubMutable(reportHubId, visible)
1247
+
1116
1248
  const existing = await db
1117
1249
  .select<DailyReportUserStatusRow>()
1118
1250
  .top(1)
@@ -1156,12 +1288,14 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1156
1288
  if (businessDate) {
1157
1289
  const normalizedDate = normalizeBusinessDateKey(businessDate)
1158
1290
  if (normalizedDate) {
1159
- sqlResultCache.invalidate(`daily-report:date:${normalizedDate}:user:${userId}`)
1291
+ // 可視集合ダイジェスト付きキーを取りこぼさないよう接頭辞で消す
1292
+ sqlResultCache.invalidatePrefix(dateUserPrefix(normalizedDate, userId))
1160
1293
  await incrementRedisEpoch(`${DAILY_REPORT_DATE_EPOCH_PREFIX}${normalizedDate}`)
1161
1294
  }
1162
1295
  }
1163
1296
  sqlResultCache.invalidate(`daily-report:detail:${reportHubId}`)
1164
- sqlResultCache.invalidate(`daily-report:detail:${reportHubId}:user:${userId}`)
1297
+ // 可視集合ダイジェスト付きキーを取りこぼさないよう接頭辞で消す
1298
+ sqlResultCache.invalidatePrefix(detailUserPrefix(reportHubId, userId))
1165
1299
  await incrementRedisEpoch(`${DAILY_REPORT_DETAIL_EPOCH_PREFIX}${reportHubId}`)
1166
1300
 
1167
1301
  await publishToSseStream(
@@ -1206,8 +1340,16 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1206
1340
  /**
1207
1341
  * Adds a comment to a daily report.
1208
1342
  * 日報にコメントを追加する。
1343
+ *
1344
+ * @param userId Internal numeric user id. 内部数値ユーザー ID。
1345
+ * @param reportHubId Hub id. 対象 hub の ID。
1346
+ * @param content Comment body. コメント本文。
1347
+ * @param businessDate Business date for cache invalidation. キャッシュ無効化に使う営業日。
1348
+ * @param clientTempId Optimistic-update correlation id. 楽観更新の相関 ID。
1349
+ * @param visible Normalized visible source-type set. 正規化済みの可視 sourceType 集合。
1350
+ * @returns The created comment. 追加されたコメント。
1209
1351
  */
1210
- const addComment = async (userId: number, reportHubId: number, content: string, businessDate: string | null, clientTempId: string): Promise<DailyReportCommentItem> => {
1352
+ const addComment = async (userId: number, reportHubId: number, content: string, businessDate: string | null, clientTempId: string, visible: VisibleSourceTypeSet): Promise<DailyReportCommentItem> => {
1211
1353
  const [hub] = await db
1212
1354
  .select<{ sourceType: string; deletedAt: Date | string | null; userId: number | null }>({ sourceType: DailyReportHub.sourceType, deletedAt: DailyReportHub.deletedAt, userId: DailyReportHub.userId })
1213
1355
  .top(1)
@@ -1218,6 +1360,11 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1218
1360
  if (!hub || hub.deletedAt) {
1219
1361
  throw new Error("Not Found")
1220
1362
  }
1363
+ // 不可視区分の日報にはコメントできない。認可失敗と不存在を区別せず Not Found にする
1364
+ // (存在オラクルの回避)
1365
+ if (!isSourceTypeVisible(visible, hub.sourceType)) {
1366
+ throw new Error("Not Found")
1367
+ }
1221
1368
  if (hub.sourceType.toLowerCase() !== "internal") {
1222
1369
  throw new Error("Comments are restricted for external daily report sources")
1223
1370
  }
@@ -1268,6 +1415,8 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1268
1415
  comment: commentItem,
1269
1416
  clientTempId,
1270
1417
  recipientRawUserId,
1418
+ // 受信側の区分フィルタ用 (旧エントリ互換のためスキーマ上は任意)
1419
+ sourceType: hub.sourceType,
1271
1420
  }),
1272
1421
  "addComment",
1273
1422
  )
@@ -1286,24 +1435,44 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1286
1435
  /**
1287
1436
  * Deletes a comment from a daily report.
1288
1437
  * 日報のコメントを削除する。
1438
+ *
1439
+ * @param userId Internal numeric user id. 内部数値ユーザー ID。
1440
+ * @param reportHubId Hub id. 対象 hub の ID。
1441
+ * @param commentId Comment id to delete. 削除対象のコメント ID。
1442
+ * @param businessDate Business date for cache invalidation. キャッシュ無効化に使う営業日。
1443
+ * @param clientTempId Optimistic-update correlation id. 楽観更新の相関 ID。
1444
+ * @param visible Normalized visible source-type set. 正規化済みの可視 sourceType 集合。
1445
+ * @returns Nothing. なし。
1289
1446
  */
1290
- const deleteComment = async (userId: number, reportHubId: number, commentId: number, businessDate: string | null, clientTempId: string): Promise<void> => {
1447
+ const deleteComment = async (userId: number, reportHubId: number, commentId: number, businessDate: string | null, clientTempId: string, visible: VisibleSourceTypeSet): Promise<void> => {
1291
1448
  const comments = await findDailyReportCommentById(db, commentId)
1292
1449
  if (comments.length === 0) {
1293
1450
  throw new Error("Not Found")
1294
1451
  }
1295
- if (comments[0].userId !== userId) {
1296
- throw new Error("Unauthorized")
1297
- }
1452
+
1298
1453
  // 渡された reportHubId がコメントの実際の所属と一致することを確認する。
1299
- // 不一致のまま進むと、無関係な日報のキャッシュ無効化と SSE 配信を引き起こす
1454
+ // 不一致のまま進むと、無関係な日報のキャッシュ無効化と SSE 配信を引き起こす。
1455
+ // ❗ 所有者チェックより**前**に置く。後ろだと、攻撃者が自分に可視な hubId を添えて任意の
1456
+ // commentId を試すことで「他人のコメント = 403 / 存在しない = 404」と応答が割れ、
1457
+ // コメントの存在を列挙できてしまう
1300
1458
  if (comments[0].hubId !== reportHubId) {
1301
1459
  throw new Error("Not Found")
1302
1460
  }
1303
1461
 
1462
+ // ❗ 区分ゲートも所有者チェックより前に置く。参照するのは**コメント自身の所属 hub** で
1463
+ // あり、呼び出し側が渡した ID ではない (上で一致を確認済み)。hub 行が引けない場合も
1464
+ // fail-close で Not Found にする (assertHubMutable と同一の規約)
1465
+ 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))
1466
+ if (!(hubForDelete && isSourceTypeVisible(visible, hubForDelete.sourceType))) {
1467
+ throw new Error("Not Found")
1468
+ }
1469
+
1470
+ if (comments[0].userId !== userId) {
1471
+ throw new Error("Unauthorized")
1472
+ }
1473
+
1304
1474
  // 下書きなら削除イベントも所有者宛に絞る (comment-add と対称)
1305
- const [hubForDelete] = await db.select<{ userId: number | null }>({ userId: DailyReportHub.userId }).top(1).from(DailyReportHub).where(eq(DailyReportHub.id, reportHubId))
1306
- const recipientRawUserId = await resolveCommentRecipient(reportHubId, hubForDelete?.userId ?? null)
1475
+ const recipientRawUserId = await resolveCommentRecipient(reportHubId, hubForDelete.userId)
1307
1476
 
1308
1477
  await db.delete(DailyReportCommentModel).where(eq(DailyReportCommentModel.id, commentId))
1309
1478
 
@@ -1326,6 +1495,8 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1326
1495
  commentId,
1327
1496
  clientTempId,
1328
1497
  recipientRawUserId,
1498
+ // 受信側の区分フィルタ用 (hub 行が引けない場合は undefined = 旧エントリ相当の扱い)
1499
+ sourceType: hubForDelete?.sourceType,
1329
1500
  }),
1330
1501
  "deleteComment",
1331
1502
  )
@@ -1430,17 +1601,24 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1430
1601
  // キャッシュ無効化
1431
1602
  const normalizedBusinessDate = normalizeBusinessDateKey(businessDate)
1432
1603
  if (normalizedBusinessDate) {
1433
- sqlResultCache.invalidate(`daily-report:date:${normalizedBusinessDate}:user:${userId}`)
1604
+ // 可視集合ダイジェスト付きキーを取りこぼさないよう接頭辞で消す
1605
+ sqlResultCache.invalidatePrefix(dateUserPrefix(normalizedBusinessDate, userId))
1434
1606
  }
1435
1607
  sqlResultCache.invalidatePrefix(DAILY_REPORT_BUSINESS_DATE_CACHE_PREFIX)
1436
- sqlResultCache.invalidate(`${DAILY_REPORT_IDS_CACHE_KEY}:user:${userId}`)
1608
+ // ❗ createDailyReport には ids の広域 prefix 無効化が無く、この 1 行が作成者の ID 一覧を
1609
+ // 落とす唯一の経路。redis 未注入の配備では epoch も進まないため、ここを消すと
1610
+ // 作成した日報が自分の一覧から TTL 分 (既定 180 秒) 消える。接頭辞化のみ行う
1611
+ sqlResultCache.invalidatePrefix(idsUserPrefix(userId))
1437
1612
  // ❗ 順序重要: invalidate → incrementRedisEpoch → publishToSseStream
1438
1613
  await incrementRedisEpoch(DAILY_REPORT_IDS_EPOCH_KEY)
1439
1614
  if (normalizedBusinessDate) {
1440
1615
  await incrementRedisEpoch(`${DAILY_REPORT_DATE_EPOCH_PREFIX}${normalizedBusinessDate}`)
1441
1616
  }
1442
1617
 
1443
- const fullDetail = await getDailyReportDetailById(result.reportHubId, userId, { forceRefresh: true })
1618
+ // ❗ 明示 null (無制限)。所有者が自分で作った日報を取り直す経路なので、消費側の可視集合に
1619
+ // 作成区分 ("Internal") が含まれなくても取得と SSE 配信を壊さない。区分の出し分けは
1620
+ // 受信側の SSE フィルタと次回読み取り時のサーバーフィルタが担う
1621
+ const fullDetail = await getDailyReportDetailById(result.reportHubId, userId, { forceRefresh: true }, null)
1444
1622
 
1445
1623
  if (fullDetail) {
1446
1624
  // 下書き判定
@@ -1541,6 +1719,8 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1541
1719
  type: "report-delete",
1542
1720
  reportHubId: reportHubId,
1543
1721
  clientTempId,
1722
+ // 受信側の区分フィルタ用 (旧エントリ互換のためスキーマ上は任意)
1723
+ sourceType: report[0].sourceType,
1544
1724
  }),
1545
1725
  "deleteDailyReport",
1546
1726
  )
@@ -1591,7 +1771,9 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1591
1771
  await incrementRedisEpoch(`${DAILY_REPORT_DATE_EPOCH_PREFIX}${editedBusinessDate}`)
1592
1772
  }
1593
1773
 
1594
- const fullDetail = await getDailyReportDetailById(reportHubId, userId, { forceRefresh: true })
1774
+ // ❗ 明示 null (無制限)。所有者が自分の日報を取り直す経路であり、区分の出し分けは
1775
+ // 受信側の SSE フィルタと次回読み取り時のサーバーフィルタが担う
1776
+ const fullDetail = await getDailyReportDetailById(reportHubId, userId, { forceRefresh: true }, null)
1595
1777
  if (fullDetail) {
1596
1778
  // 下書き判定
1597
1779
  // 判定は代表 ID ではなく下書きラベル**全体**で行う (2 つ目の下書き名を見落とさない)
@@ -1645,10 +1827,13 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
1645
1827
  // ユーザーごとの営業日別一覧キャッシュも無効化
1646
1828
  const normalizedBusinessDate = normalizeBusinessDateKey(formatDateValue(report[0].businessDate, "YYYY-MM-DD"))
1647
1829
  if (normalizedBusinessDate) {
1648
- sqlResultCache.invalidate(`daily-report:date:${normalizedBusinessDate}:user:${userId}`)
1830
+ // 可視集合ダイジェスト付きキーを取りこぼさないよう接頭辞で消す
1831
+ sqlResultCache.invalidatePrefix(dateUserPrefix(normalizedBusinessDate, userId))
1649
1832
  }
1650
1833
 
1651
- const fullDetail = await getDailyReportDetailById(reportHubId, userId, { forceRefresh: true })
1834
+ // ❗ 明示 null (無制限)。所有者が自分の日報を取り直す経路であり、区分の出し分けは
1835
+ // 受信側の SSE フィルタと次回読み取り時のサーバーフィルタが担う
1836
+ const fullDetail = await getDailyReportDetailById(reportHubId, userId, { forceRefresh: true }, null)
1652
1837
  if (fullDetail) {
1653
1838
  await publishToSseStream(
1654
1839
  reportPublishMessageSchema.parse({