@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.
- package/README.md +32 -0
- package/dist/client.d.mts +1 -1
- package/dist/client.d.ts +1 -1
- package/dist/client.js +5 -5
- package/dist/client.js.map +1 -1
- package/dist/client.mjs +5 -5
- package/dist/client.mjs.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/server.d.mts +175 -21
- package/dist/server.d.ts +175 -21
- package/dist/server.js +12 -9
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +12 -9
- package/dist/server.mjs.map +1 -1
- package/dist/{sse-schema-YZh7Pz7i.d.ts → sse-schema-D1oZnFg6.d.ts} +18 -0
- package/dist/{sse-schema-BiUnufrI.d.mts → sse-schema-lMo8yW4e.d.mts} +18 -0
- package/package.json +1 -1
- package/src/client/components/daily-report-attachment-indicator.tsx +1 -1
- package/src/client/components/daily-report-detail-list.tsx +3 -3
- package/src/client/components/daily-report-ids-stream-status.tsx +1 -1
- package/src/client/components/daily-report-list.tsx +3 -3
- package/src/client/components/daily-report-resolved-content.tsx +3 -3
- package/src/lucide-icons.d.ts +3 -3
- package/src/server/handlers.ts +181 -35
- package/src/server/handlers.visibility.spec.ts +458 -0
- package/src/server/ports.ts +48 -0
- package/src/server/service.spec.ts +335 -14
- package/src/server/service.ts +222 -37
- package/src/server/test-helpers/handlers-config.ts +93 -9
- package/src/server/visibility.spec.ts +142 -0
- package/src/server/visibility.ts +189 -0
- package/src/server.spec.ts +72 -0
- package/src/server.ts +20 -1
- package/src/shared/sse-schema.ts +15 -0
package/src/server/service.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1604
|
+
// 可視集合ダイジェスト付きキーを取りこぼさないよう接頭辞で消す
|
|
1605
|
+
sqlResultCache.invalidatePrefix(dateUserPrefix(normalizedBusinessDate, userId))
|
|
1434
1606
|
}
|
|
1435
1607
|
sqlResultCache.invalidatePrefix(DAILY_REPORT_BUSINESS_DATE_CACHE_PREFIX)
|
|
1436
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1830
|
+
// 可視集合ダイジェスト付きキーを取りこぼさないよう接頭辞で消す
|
|
1831
|
+
sqlResultCache.invalidatePrefix(dateUserPrefix(normalizedBusinessDate, userId))
|
|
1649
1832
|
}
|
|
1650
1833
|
|
|
1651
|
-
|
|
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({
|