@aiquants/daily-report 0.7.0 → 0.9.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 +13 -6
- package/dist/client.d.mts +438 -26
- package/dist/client.d.ts +438 -26
- package/dist/client.js +5 -4
- package/dist/client.js.map +1 -1
- package/dist/client.mjs +5 -4
- package/dist/client.mjs.map +1 -1
- package/dist/index.d.mts +115 -3
- package/dist/index.d.ts +115 -3
- 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 +15 -0
- package/dist/server.d.ts +15 -0
- package/dist/server.js +9 -6
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +9 -6
- package/dist/server.mjs.map +1 -1
- package/dist/{sse-schema-Df49KA7B.d.mts → sse-schema-BiUnufrI.d.mts} +12 -0
- package/dist/{sse-schema-RHckD7TS.d.ts → sse-schema-YZh7Pz7i.d.ts} +12 -0
- package/dist/styles/daily-report.standalone.css +1 -1
- package/package.json +3 -3
- package/src/client/components/daily-report-comment-item.tsx +6 -1
- package/src/client/components/daily-report-detail-list.tsx +65 -34
- package/src/client/components/daily-report-edit-form.tsx +6 -0
- package/src/client/components/daily-report-ids-stream-status.spec.tsx +93 -0
- package/src/client/components/daily-report-ids-stream-status.tsx +67 -0
- package/src/client/components/daily-report-list.tsx +88 -55
- package/src/client/components/daily-report-page.spec.tsx +119 -0
- package/src/client/components/daily-report-page.tsx +62 -69
- package/src/client/components/daily-report-resolved-content.tsx +36 -10
- package/src/client/components/report-views.spec.tsx +37 -2
- package/src/client/config-context.tsx +18 -0
- package/src/client/contexts/daily-report-action-context.spec.tsx +278 -0
- package/src/client/contexts/daily-report-action-context.tsx +203 -65
- package/src/client/contexts/daily-report-error-context.spec.tsx +66 -0
- package/src/client/contexts/daily-report-error-context.tsx +98 -0
- package/src/client/hooks/use-animated-number.spec.ts +103 -0
- package/src/client/hooks/use-animated-number.ts +69 -0
- package/src/client/hooks/use-daily-report.spec.ts +50 -2
- package/src/client/hooks/use-daily-report.ts +30 -1
- package/src/client/route-helpers.spec.ts +100 -0
- package/src/client/route-helpers.ts +20 -41
- package/src/client/streaming/daily-report-ids-stream-client.spec.ts +704 -0
- package/src/client/streaming/daily-report-ids-stream-client.ts +677 -0
- package/src/client/streaming/daily-report-ids-stream-session.spec.ts +360 -0
- package/src/client/streaming/daily-report-ids-stream-session.ts +280 -0
- package/src/client.ts +5 -0
- package/src/index.ts +1 -0
- package/src/server/handlers.action.spec.ts +44 -0
- package/src/server/handlers.ids-stream.spec.ts +414 -0
- package/src/server/handlers.ts +324 -8
- package/src/server/service.spec.ts +83 -7
- package/src/server/service.ts +100 -3
- package/src/server/test-helpers/handlers-config.ts +48 -1
- package/src/server.spec.ts +49 -0
- package/src/server.ts +7 -0
- package/src/shared/business-date.spec.ts +22 -1
- package/src/shared/business-date.ts +20 -0
- package/src/shared/comment-adapter.spec.ts +38 -0
- package/src/shared/comment-adapter.ts +9 -7
- package/src/shared/ids-stream.spec.ts +65 -0
- package/src/shared/ids-stream.ts +139 -0
- package/src/shared/sse-schema.ts +5 -0
- package/src/shared/text-utils.spec.ts +42 -0
- package/src/shared/text-utils.ts +8 -0
package/src/server/service.ts
CHANGED
|
@@ -419,6 +419,45 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
419
419
|
}))
|
|
420
420
|
}
|
|
421
421
|
|
|
422
|
+
/**
|
|
423
|
+
* Fetches only the first page of report ids for time-to-first-render (uncached).
|
|
424
|
+
* 最初の描画までの時間専用に、ID 一覧の先頭ページだけを取得する内部処理 (キャッシュ無し)。
|
|
425
|
+
*
|
|
426
|
+
* `ids-stream` の新規接続でキャッシュミスした場合、全量クエリの確定を待たずに
|
|
427
|
+
* この軽量クエリ (TOP n・インデックス順で n 行走査した時点で打ち切り) の結果を
|
|
428
|
+
* 最初のチャンクとして送出する。可視性述語と並び順は全量クエリと完全同一。
|
|
429
|
+
* `.top(n)` は `.select()` の直後でなければならない (drizzle-mssql の構造的制約)。
|
|
430
|
+
*
|
|
431
|
+
* @param externalId External user id. 外部ユーザー ID。
|
|
432
|
+
* @param limit Page size. ページサイズ。
|
|
433
|
+
* @returns First-page items in stream order. ストリーム順の先頭ページ。
|
|
434
|
+
*/
|
|
435
|
+
const getDailyReportIdsFirstPageByExternalId = async (externalId: string, limit: number): Promise<DailyReportItem[]> => {
|
|
436
|
+
const userId = await getUserIdByExternalId(externalId)
|
|
437
|
+
if (!userId) {
|
|
438
|
+
return []
|
|
439
|
+
}
|
|
440
|
+
const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
|
|
441
|
+
const draftLabelJoinIds = await getDraftLabelJoinIds()
|
|
442
|
+
|
|
443
|
+
const result = await db
|
|
444
|
+
.select<{ reportHubId: number; businessDate: Date | string | null; sourceType: string }>({
|
|
445
|
+
reportHubId: DailyReportHub.id,
|
|
446
|
+
businessDate: DailyReportHub.businessDate,
|
|
447
|
+
sourceType: DailyReportHub.sourceType,
|
|
448
|
+
})
|
|
449
|
+
.top(limit)
|
|
450
|
+
.from(DailyReportHub)
|
|
451
|
+
.leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
|
|
452
|
+
.where(and(isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DailyReportHub.userId), isNull(DraftLabelRelation.hubId))))
|
|
453
|
+
.orderBy(desc(DailyReportHub.businessDate), desc(DailyReportHub.id))
|
|
454
|
+
|
|
455
|
+
return result.map((item) => ({
|
|
456
|
+
...item,
|
|
457
|
+
businessDate: formatDateValue(item.businessDate, "YYYY-MM-DD"),
|
|
458
|
+
}))
|
|
459
|
+
}
|
|
460
|
+
|
|
422
461
|
/**
|
|
423
462
|
* Fetches daily report details for the provided business date.
|
|
424
463
|
* 指定した営業日の日報詳細を取得する内部処理。
|
|
@@ -1114,15 +1153,50 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1114
1153
|
return updatedStatus
|
|
1115
1154
|
}
|
|
1116
1155
|
|
|
1156
|
+
/**
|
|
1157
|
+
* Resolves the SSE recipient for comment events on a possibly-draft report.
|
|
1158
|
+
* 下書きの可能性がある日報のコメントイベントについて SSE の宛先を解決する処理。
|
|
1159
|
+
*
|
|
1160
|
+
* 下書きは所有者にしか見えないため、下書きラベルが付いている間はコメント本文も
|
|
1161
|
+
* 所有者だけに配信する (無指定だと本文が全ユーザーへブロードキャストされる)。
|
|
1162
|
+
* 公開済み (下書きラベル無し) は全員配信 = undefined を返す。
|
|
1163
|
+
*
|
|
1164
|
+
* @param hubId Parent hub id. 親日報の hub ID。
|
|
1165
|
+
* @param ownerUserId Hub owner's raw user id. 所有者の内部ユーザー ID。
|
|
1166
|
+
* @returns Recipient raw user id, or undefined to broadcast. 宛先 (全員配信は undefined)。
|
|
1167
|
+
*/
|
|
1168
|
+
const resolveCommentRecipient = async (hubId: number, ownerUserId: number | null): Promise<number | undefined> => {
|
|
1169
|
+
// 所有者不明 (共有日報など) は従来どおり全員配信
|
|
1170
|
+
if (ownerUserId === null) return undefined
|
|
1171
|
+
const draftIds = await getDraftLabelJoinIds()
|
|
1172
|
+
const [rel] = await db
|
|
1173
|
+
.select<{ hubId: number }>({ hubId: DailyReportHub_Label.hubId })
|
|
1174
|
+
.top(1)
|
|
1175
|
+
.from(DailyReportHub_Label)
|
|
1176
|
+
.where(and(eq(DailyReportHub_Label.hubId, hubId), inArray(DailyReportHub_Label.labelId, draftIds)))
|
|
1177
|
+
return rel ? ownerUserId : undefined
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1117
1180
|
/**
|
|
1118
1181
|
* Adds a comment to a daily report.
|
|
1119
1182
|
* 日報にコメントを追加する。
|
|
1120
1183
|
*/
|
|
1121
1184
|
const addComment = async (userId: number, reportHubId: number, content: string, businessDate: string | null, clientTempId: string): Promise<DailyReportCommentItem> => {
|
|
1122
|
-
const [hub] = await db
|
|
1123
|
-
|
|
1185
|
+
const [hub] = await db
|
|
1186
|
+
.select<{ sourceType: string; deletedAt: Date | string | null; userId: number | null }>({ sourceType: DailyReportHub.sourceType, deletedAt: DailyReportHub.deletedAt, userId: DailyReportHub.userId })
|
|
1187
|
+
.top(1)
|
|
1188
|
+
.from(DailyReportHub)
|
|
1189
|
+
.where(eq(DailyReportHub.id, reportHubId))
|
|
1190
|
+
// 存在しない / 論理削除済みの日報にはコメントを付けない (以前は hub 未取得でも
|
|
1191
|
+
// フォールスルーして孤児コメントを INSERT していた)
|
|
1192
|
+
if (!hub || hub.deletedAt) {
|
|
1193
|
+
throw new Error("Not Found")
|
|
1194
|
+
}
|
|
1195
|
+
if (hub.sourceType.toLowerCase() !== "internal") {
|
|
1124
1196
|
throw new Error("Comments are restricted for external daily report sources")
|
|
1125
1197
|
}
|
|
1198
|
+
// 下書きなら SSE を所有者宛に絞る (本文の全員配信を防ぐ)
|
|
1199
|
+
const recipientRawUserId = await resolveCommentRecipient(reportHubId, hub.userId)
|
|
1126
1200
|
|
|
1127
1201
|
const [inserted] = await db
|
|
1128
1202
|
.insert<DailyReportCommentRow>(DailyReportCommentModel)
|
|
@@ -1167,6 +1241,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1167
1241
|
reportHubId: reportHubId,
|
|
1168
1242
|
comment: commentItem,
|
|
1169
1243
|
clientTempId,
|
|
1244
|
+
recipientRawUserId,
|
|
1170
1245
|
}),
|
|
1171
1246
|
"addComment",
|
|
1172
1247
|
)
|
|
@@ -1194,6 +1269,15 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1194
1269
|
if (comments[0].userId !== userId) {
|
|
1195
1270
|
throw new Error("Unauthorized")
|
|
1196
1271
|
}
|
|
1272
|
+
// 渡された reportHubId がコメントの実際の所属と一致することを確認する。
|
|
1273
|
+
// 不一致のまま進むと、無関係な日報のキャッシュ無効化と SSE 配信を引き起こす
|
|
1274
|
+
if (comments[0].hubId !== reportHubId) {
|
|
1275
|
+
throw new Error("Not Found")
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// 下書きなら削除イベントも所有者宛に絞る (comment-add と対称)
|
|
1279
|
+
const [hubForDelete] = await db.select<{ userId: number | null }>({ userId: DailyReportHub.userId }).top(1).from(DailyReportHub).where(eq(DailyReportHub.id, reportHubId))
|
|
1280
|
+
const recipientRawUserId = await resolveCommentRecipient(reportHubId, hubForDelete?.userId ?? null)
|
|
1197
1281
|
|
|
1198
1282
|
await db.delete(DailyReportCommentModel).where(eq(DailyReportCommentModel.id, commentId))
|
|
1199
1283
|
|
|
@@ -1215,6 +1299,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1215
1299
|
reportHubId: reportHubId,
|
|
1216
1300
|
commentId,
|
|
1217
1301
|
clientTempId,
|
|
1302
|
+
recipientRawUserId,
|
|
1218
1303
|
}),
|
|
1219
1304
|
"deleteComment",
|
|
1220
1305
|
)
|
|
@@ -1465,9 +1550,20 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1465
1550
|
}
|
|
1466
1551
|
})
|
|
1467
1552
|
|
|
1468
|
-
//
|
|
1553
|
+
// キャッシュ無効化: 一覧プレビューは詳細キャッシュから引くため、詳細を全プロセス横断で落とす。
|
|
1554
|
+
// 以前は読み取りに使われないプレフィックスを無効化し epoch も上げていなかったため、
|
|
1555
|
+
// 編集後の件名/本文が他ワーカーで古いまま配信されていた
|
|
1556
|
+
sqlResultCache.invalidate(`daily-report:detail:${reportHubId}`)
|
|
1469
1557
|
sqlResultCache.invalidatePrefix(`daily-report:detail:${reportHubId}:user:`)
|
|
1470
1558
|
sqlResultCache.invalidatePrefix(DAILY_REPORT_BUSINESS_DATE_CACHE_PREFIX)
|
|
1559
|
+
await incrementRedisEpoch(`${DAILY_REPORT_DETAIL_EPOCH_PREFIX}${reportHubId}`)
|
|
1560
|
+
// 編集内容は全ユーザーの一覧に映るため、営業日別一覧は全ユーザー分 + 全ワーカー横断で
|
|
1561
|
+
// 無効化する (addComment と同じ流儀。編集者本人のキーだけでは他者へ古い件名が残る)
|
|
1562
|
+
const editedBusinessDate = normalizeBusinessDateKey(formatDateValue(report[0].businessDate, "YYYY-MM-DD"))
|
|
1563
|
+
if (editedBusinessDate) {
|
|
1564
|
+
sqlResultCache.invalidatePrefix(`daily-report:date:${editedBusinessDate}:user:`)
|
|
1565
|
+
await incrementRedisEpoch(`${DAILY_REPORT_DATE_EPOCH_PREFIX}${editedBusinessDate}`)
|
|
1566
|
+
}
|
|
1471
1567
|
|
|
1472
1568
|
const fullDetail = await getDailyReportDetailById(reportHubId, userId, { forceRefresh: true })
|
|
1473
1569
|
if (fullDetail) {
|
|
@@ -1600,6 +1696,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1600
1696
|
getUserIdByExternalId,
|
|
1601
1697
|
// 参照系
|
|
1602
1698
|
getDailyReportIdsByExternalId,
|
|
1699
|
+
getDailyReportIdsFirstPageByExternalId,
|
|
1603
1700
|
getDailyReportsByBusinessDate,
|
|
1604
1701
|
getDailyReportsByBusinessDateByExternalId,
|
|
1605
1702
|
getDailyReportDetailById,
|
|
@@ -51,15 +51,31 @@ export type HandlersFixtureOptions = {
|
|
|
51
51
|
attachmentRateLimitPerMinute?: number
|
|
52
52
|
/** 同時実行上限。 */
|
|
53
53
|
attachmentConcurrency?: number
|
|
54
|
+
/** `intent=clearCache` (開発用の全キャッシュ破棄) を許可するか。 */
|
|
55
|
+
enableDevCacheClear?: boolean
|
|
56
|
+
/** ids ストリームが返すアイテム一覧 (ストリーム順で渡す)。 */
|
|
57
|
+
idsStreamItems?: Array<{ reportHubId: number; businessDate: string | null; sourceType?: string }>
|
|
58
|
+
/** ids 取得 (全量・先頭ページの両方) を失敗させるエラー。 */
|
|
59
|
+
idsStreamError?: Error
|
|
60
|
+
/** 全量クエリの解決を保留にする Promise (高速先頭ページ経路の検証用)。 */
|
|
61
|
+
idsStreamFullGate?: Promise<void>
|
|
62
|
+
/** 先頭ページの結果を上書きする (既定は idsStreamItems の先頭スライス)。 */
|
|
63
|
+
idsStreamFirstPageItems?: Array<{ reportHubId: number; businessDate: string | null; sourceType?: string }>
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
/** 呼び出し記録。テストが副作用を検証するために使う。 */
|
|
57
67
|
export type HandlersFixtureCalls = {
|
|
68
|
+
/** `service.clearCache` の呼び出し回数。 */
|
|
69
|
+
clearCache: number
|
|
58
70
|
/** `service.getAttachmentForUser` が受け取った `(attachmentId, viewerUserId)`。 */
|
|
59
71
|
getAttachmentForUser: Array<[number, number]>
|
|
60
72
|
readAttachment: Array<{ filePath: string; maxBytes: number; head: boolean; principal?: string }>
|
|
61
73
|
markAttachmentMissing: number[]
|
|
62
74
|
markAttachmentPresent: number[]
|
|
75
|
+
/** `service.getDailyReportIdsByExternalId` が受け取った `(externalId, forceRefresh)`。 */
|
|
76
|
+
getDailyReportIds: Array<[string, boolean]>
|
|
77
|
+
/** `service.getDailyReportIdsFirstPageByExternalId` が受け取った `(externalId, limit)`。 */
|
|
78
|
+
getDailyReportIdsFirstPage: Array<[string, number]>
|
|
63
79
|
}
|
|
64
80
|
|
|
65
81
|
/**
|
|
@@ -70,7 +86,7 @@ export type HandlersFixtureCalls = {
|
|
|
70
86
|
* @returns The config and its call log. 設定と呼び出し記録。
|
|
71
87
|
*/
|
|
72
88
|
export const makeHandlersFixture = (options: HandlersFixtureOptions = {}): { config: DailyReportHandlersConfig; calls: HandlersFixtureCalls } => {
|
|
73
|
-
const calls: HandlersFixtureCalls = { getAttachmentForUser: [], readAttachment: [], markAttachmentMissing: [], markAttachmentPresent: [] }
|
|
89
|
+
const calls: HandlersFixtureCalls = { clearCache: 0, getAttachmentForUser: [], readAttachment: [], markAttachmentMissing: [], markAttachmentPresent: [], getDailyReportIds: [], getDailyReportIdsFirstPage: [] }
|
|
74
90
|
|
|
75
91
|
const user = options.user === undefined ? { id: TEST_EXTERNAL_USER_ID } : options.user
|
|
76
92
|
const internalUserId = options.internalUserId === undefined ? TEST_INTERNAL_USER_ID : options.internalUserId
|
|
@@ -79,6 +95,20 @@ export const makeHandlersFixture = (options: HandlersFixtureOptions = {}): { con
|
|
|
79
95
|
|
|
80
96
|
const service = {
|
|
81
97
|
getUserIdByExternalId: async () => internalUserId,
|
|
98
|
+
clearCache: async () => {
|
|
99
|
+
calls.clearCache += 1
|
|
100
|
+
},
|
|
101
|
+
getDailyReportIdsByExternalId: async (externalId: string, opts?: { forceRefresh?: boolean }) => {
|
|
102
|
+
calls.getDailyReportIds.push([externalId, opts?.forceRefresh ?? false])
|
|
103
|
+
if (options.idsStreamError) throw options.idsStreamError
|
|
104
|
+
if (options.idsStreamFullGate) await options.idsStreamFullGate
|
|
105
|
+
return options.idsStreamItems ?? []
|
|
106
|
+
},
|
|
107
|
+
getDailyReportIdsFirstPageByExternalId: async (externalId: string, limit: number) => {
|
|
108
|
+
calls.getDailyReportIdsFirstPage.push([externalId, limit])
|
|
109
|
+
if (options.idsStreamError) throw options.idsStreamError
|
|
110
|
+
return options.idsStreamFirstPageItems ?? (options.idsStreamItems ?? []).slice(0, limit)
|
|
111
|
+
},
|
|
82
112
|
getAttachmentForUser: async (attachmentId: number, viewerUserId: number) => {
|
|
83
113
|
// 引数を記録する。記録しないと「閲覧者 ID を渡し忘れた」欠陥がテストに映らない
|
|
84
114
|
calls.getAttachmentForUser.push([attachmentId, viewerUserId])
|
|
@@ -101,6 +131,7 @@ export const makeHandlersFixture = (options: HandlersFixtureOptions = {}): { con
|
|
|
101
131
|
attachmentMaxBytes: options.attachmentMaxBytes,
|
|
102
132
|
attachmentRateLimitPerMinute: options.attachmentRateLimitPerMinute,
|
|
103
133
|
attachmentConcurrency: options.attachmentConcurrency,
|
|
134
|
+
enableDevCacheClear: options.enableDevCacheClear,
|
|
104
135
|
}
|
|
105
136
|
|
|
106
137
|
if (options.omitPorts) {
|
|
@@ -133,6 +164,22 @@ export const makeHandlersFixture = (options: HandlersFixtureOptions = {}): { con
|
|
|
133
164
|
* @param init Optional method / query / headers. メソッド・クエリ・ヘッダーの任意指定。
|
|
134
165
|
* @returns Loader args. loader 引数。
|
|
135
166
|
*/
|
|
167
|
+
/**
|
|
168
|
+
* Builds loader args for the `:endpoint` API router.
|
|
169
|
+
* `:endpoint` API ルーター用の loader 引数を組み立てる処理。
|
|
170
|
+
*
|
|
171
|
+
* @param endpoint Endpoint segment (e.g. "ids-stream"). エンドポイントセグメント。
|
|
172
|
+
* @param init Optional method / query / signal. メソッド・クエリ・中断シグナルの任意指定。
|
|
173
|
+
* @returns Loader args. loader 引数。
|
|
174
|
+
*/
|
|
175
|
+
export const makeApiArgs = (endpoint: string, init: { method?: string; query?: string; signal?: AbortSignal } = {}) => ({
|
|
176
|
+
request: new Request(`http://localhost/daily_report/api/${endpoint}${init.query ?? ""}`, {
|
|
177
|
+
method: init.method ?? "GET",
|
|
178
|
+
signal: init.signal ?? undefined,
|
|
179
|
+
}),
|
|
180
|
+
params: { endpoint } as Record<string, string | undefined>,
|
|
181
|
+
})
|
|
182
|
+
|
|
136
183
|
export const makeAttachmentArgs = (token: string, init: { method?: string; query?: string; headers?: Record<string, string> } = {}) => ({
|
|
137
184
|
request: new Request(`http://localhost/daily_report/api/attachment/${encodeURIComponent(token)}${init.query ?? ""}`, {
|
|
138
185
|
method: init.method ?? "GET",
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Facade tests for createDailyReportServer: the wiring the explicit-enumeration call must forward.
|
|
3
|
+
* createDailyReportServer ファサードのテスト。明示列挙の結線が取りこぼしていないことを固定する。
|
|
4
|
+
*/
|
|
5
|
+
import { bigint, mssqlSchema, nvarchar, primaryKey } from "drizzle-orm/mssql-core"
|
|
6
|
+
import { describe, expect, it } from "vitest"
|
|
7
|
+
import { createDailyReportServer, type DailyReportDb } from "./server"
|
|
8
|
+
import { defineDailyReportSchema } from "./server/schema"
|
|
9
|
+
|
|
10
|
+
const testUsers = mssqlSchema("test").table("Users", { id: bigint("id", { mode: "number" }).notNull(), displayName: nvarchar("display_name", { length: 100 }) }, (t) => [primaryKey({ columns: [t.id] })])
|
|
11
|
+
const tables = defineDailyReportSchema("test", { userTable: testUsers })
|
|
12
|
+
|
|
13
|
+
/** どのクエリも空行を返す最小 db (clearCache 経路は DB に触れない)。 */
|
|
14
|
+
const noopDb = { select: () => ({ from: () => ({ where: () => Promise.resolve([]), top: () => ({ from: () => ({ where: () => Promise.resolve([]) }) }) }) }) } as unknown as DailyReportDb
|
|
15
|
+
|
|
16
|
+
/** clearCache の POST 引数。 */
|
|
17
|
+
const clearCacheArgs = () => {
|
|
18
|
+
const body = new FormData()
|
|
19
|
+
body.set("intent", "clearCache")
|
|
20
|
+
return { request: new Request("http://localhost/daily_report/api/action", { method: "POST", body }), params: { endpoint: "action" } as Record<string, string | undefined> }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const makeServer = (enableDevCacheClear?: boolean) =>
|
|
24
|
+
createDailyReportServer({
|
|
25
|
+
db: noopDb,
|
|
26
|
+
tables,
|
|
27
|
+
userTable: testUsers,
|
|
28
|
+
resolveUserId: async () => 8,
|
|
29
|
+
encodeUserId: (id) => `ENC${id}`,
|
|
30
|
+
authenticate: async () => ({ user: { id: "ext-8" }, cookie: null }),
|
|
31
|
+
draftLabelName: "draft",
|
|
32
|
+
enableDevCacheClear,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe("createDailyReportServer: enableDevCacheClear の結線", () => {
|
|
36
|
+
it("既定 (未指定) では clearCache を 400 で拒否する", async () => {
|
|
37
|
+
const server = makeServer()
|
|
38
|
+
const res = await server.api.action(clearCacheArgs())
|
|
39
|
+
expect(res.status).toBe(400)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("enableDevCacheClear:true をファサードへ渡すとハンドラまで伝播し 200 になる", async () => {
|
|
43
|
+
// ❗ この経路が createDailyReportServer の明示列挙で取りこぼされると、アプリが
|
|
44
|
+
// フラグを渡しても黙って無視される (回帰検知の要)
|
|
45
|
+
const server = makeServer(true)
|
|
46
|
+
const res = await server.api.action(clearCacheArgs())
|
|
47
|
+
expect(res.status).toBe(200)
|
|
48
|
+
})
|
|
49
|
+
})
|
package/src/server.ts
CHANGED
|
@@ -34,6 +34,12 @@ export type DailyReportServerConfig = Omit<DailyReportServiceConfig, "cache" | "
|
|
|
34
34
|
attachmentConcurrency?: number
|
|
35
35
|
/** 未ログイン時のリダイレクト先 (index.loader 用、既定 "/auth/login")。 */
|
|
36
36
|
loginRedirectPath?: string
|
|
37
|
+
/**
|
|
38
|
+
* `intent=clearCache` (全サーバーキャッシュの一括破棄) を許可するか。既定 false。
|
|
39
|
+
* 開発用アフォーダンス。有効化すると認証済みなら誰でも全ワーカーのキャッシュを
|
|
40
|
+
* 無制限破棄できるため、本番では無効のままにする (消費アプリは開発環境のみ true 注入)。
|
|
41
|
+
*/
|
|
42
|
+
enableDevCacheClear?: boolean
|
|
37
43
|
/** SQL 結果キャッシュの既定 TTL (既定 60,000ms)。 */
|
|
38
44
|
cacheDefaultTtlMs?: number
|
|
39
45
|
/** 外部ソースアダプタ群 (レガシー日報テーブル等)。 */
|
|
@@ -80,6 +86,7 @@ export function createDailyReportServer(config: DailyReportServerConfig) {
|
|
|
80
86
|
sseReader,
|
|
81
87
|
streamKey: service.streamKey,
|
|
82
88
|
loginRedirectPath: config.loginRedirectPath,
|
|
89
|
+
enableDevCacheClear: config.enableDevCacheClear,
|
|
83
90
|
logger: config.logger,
|
|
84
91
|
})
|
|
85
92
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import { formatBusinessDateDisplay, normalizeBusinessDateKey, parseBusinessDateKeyToDate } from "./business-date"
|
|
2
|
+
import { formatBusinessDateDisplay, normalizeBusinessDateKey, parseBusinessDateKeyToDate, resolveTodayBusinessDateKey } from "./business-date"
|
|
3
3
|
|
|
4
4
|
describe("business-date utils", () => {
|
|
5
5
|
describe("normalizeBusinessDateKey", () => {
|
|
@@ -58,4 +58,25 @@ describe("business-date utils", () => {
|
|
|
58
58
|
expect(parseBusinessDateKeyToDate("invalid")).toBeNull()
|
|
59
59
|
})
|
|
60
60
|
})
|
|
61
|
+
|
|
62
|
+
describe("resolveTodayBusinessDateKey", () => {
|
|
63
|
+
it("uses the LOCAL calendar day (getFullYear/Month/Date), not the UTC date", () => {
|
|
64
|
+
// ローカルコンストラクタで作った時刻はローカル getter で読むため、ホストの TZ に
|
|
65
|
+
// 依存せず必ずその暦日を返す (旧実装の toISOString は UTC 日付で前日へずれ得た)。
|
|
66
|
+
// ❗ この検証は toISOString と対比しない: 対比はホスト TZ 依存でテスト自体が脆くなる
|
|
67
|
+
const localMorning = new Date(2026, 7, 9, 2, 30, 0)
|
|
68
|
+
expect(resolveTodayBusinessDateKey(localMorning)).toBe("2026-08-09")
|
|
69
|
+
// 別の暦日でも成立する (getter 経由であることの確認)
|
|
70
|
+
expect(resolveTodayBusinessDateKey(new Date(2025, 11, 31, 23, 59, 59))).toBe("2025-12-31")
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("zero-pads month and day", () => {
|
|
74
|
+
expect(resolveTodayBusinessDateKey(new Date(2026, 0, 3, 12, 0, 0))).toBe("2026-01-03")
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("falls back to now for an invalid injected date", () => {
|
|
78
|
+
const result = resolveTodayBusinessDateKey(new Date(Number.NaN))
|
|
79
|
+
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
61
82
|
})
|
|
@@ -23,6 +23,26 @@ const normalizeFromDate = (value: Date): string | null => {
|
|
|
23
23
|
return `${year}-${month}-${day}`
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Returns today's business-date key (YYYY-MM-DD) in the local calendar.
|
|
28
|
+
* ローカルカレンダー上の「本日」の営業日キー (YYYY-MM-DD) を返す。
|
|
29
|
+
*
|
|
30
|
+
* `new Date().toISOString()` の日付部は UTC 日付になり、UTC より東のタイムゾーン
|
|
31
|
+
* (JST など) では深夜〜午前帯に前日へずれる。営業日はブラウザのローカル暦日で判定する
|
|
32
|
+
* ため、ローカル getter から直接組む。タイムゾーンはパッケージに固定しない (消費側の
|
|
33
|
+
* 実行環境が決める) 方針に沿う。
|
|
34
|
+
*
|
|
35
|
+
* @param now Clock injection for tests (defaults to the current time). テスト用の時刻注入 (既定は現在時刻)。
|
|
36
|
+
* @returns Local calendar day as YYYY-MM-DD. ローカル暦日の YYYY-MM-DD。
|
|
37
|
+
*/
|
|
38
|
+
export const resolveTodayBusinessDateKey = (now: Date = new Date()): string => {
|
|
39
|
+
if (Number.isNaN(now.getTime())) {
|
|
40
|
+
// 不正な Date が注入されたら現在時刻で解決する (空の営業日キーを返さない)
|
|
41
|
+
return resolveTodayBusinessDateKey(new Date())
|
|
42
|
+
}
|
|
43
|
+
return `${now.getFullYear()}-${zeroPad(now.getMonth() + 1)}-${zeroPad(now.getDate())}`
|
|
44
|
+
}
|
|
45
|
+
|
|
26
46
|
/**
|
|
27
47
|
* Normalizes business date input into YYYY-MM-DD format string.
|
|
28
48
|
* 営業日を YYYY-MM-DD 形式の文字列へ正規化する。
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for comment adapters (legacy + relational merge, colour mapping).
|
|
3
|
+
* コメントアダプタ (レガシー + リレーショナル統合・色マッピング) のテスト。
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it } from "vitest"
|
|
6
|
+
import { mergeComments, resolveCommentColorClass } from "./comment-adapter"
|
|
7
|
+
|
|
8
|
+
describe("comment-adapter", () => {
|
|
9
|
+
describe("resolveCommentColorClass", () => {
|
|
10
|
+
// ダークカード上で暗色トークン単体だと文字が沈む。全分岐に dark: 対応を要求する
|
|
11
|
+
it.each([
|
|
12
|
+
[null, "text-gray-700 dark:text-gray-300"],
|
|
13
|
+
["", "text-gray-700 dark:text-gray-300"],
|
|
14
|
+
["red", "text-red-600 dark:text-red-400"],
|
|
15
|
+
["赤", "text-red-600 dark:text-red-400"],
|
|
16
|
+
["green", "text-green-600 dark:text-green-400"],
|
|
17
|
+
["青", "text-blue-600 dark:text-blue-400"],
|
|
18
|
+
["yellow", "text-yellow-600 dark:text-yellow-400"],
|
|
19
|
+
["橙", "text-orange-500 dark:text-orange-400"],
|
|
20
|
+
["unknown-colour", "text-gray-700 dark:text-gray-300"],
|
|
21
|
+
])("maps %s to a class that carries a dark counterpart", (input, expected) => {
|
|
22
|
+
const result = resolveCommentColorClass(input)
|
|
23
|
+
expect(result).toBe(expected)
|
|
24
|
+
// すべての分岐がダークモード対応を必ず含む (退行検知)
|
|
25
|
+
expect(result).toContain("dark:")
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe("mergeComments", () => {
|
|
30
|
+
it("keeps legacy JSON comments (NI 由来) alongside relational comments", () => {
|
|
31
|
+
// レガシー経路は NI 旧日報テーブルの `コメント一覧` 列由来の実データ。撤去不可
|
|
32
|
+
const merged = mergeComments([{ name: "田中", text: "レガシー本文", color: "red" }], [{ id: 7, userName: "佐藤", content: "新規本文", createdAt: "2026-08-08 10:00:00", userId: "u1", isMine: false }], "u1")
|
|
33
|
+
expect(merged).toHaveLength(2)
|
|
34
|
+
expect(merged[0]).toMatchObject({ authorName: "田中", content: "レガシー本文", isLegacy: true })
|
|
35
|
+
expect(merged[1]).toMatchObject({ authorName: "佐藤", content: "新規本文", isLegacy: false, isMine: true })
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -48,31 +48,33 @@ export const mergeComments = (legacyComments: DailyReportComment[], modernCommen
|
|
|
48
48
|
* コメント色のキーワードを Tailwind のテキストクラスに変換する関数。
|
|
49
49
|
*/
|
|
50
50
|
export const resolveCommentColorClass = (color: string | null | undefined): string => {
|
|
51
|
+
// 各色にダークモードの明色対応を必ず添える。ダークカード上で暗色トークン
|
|
52
|
+
// (text-gray-700 など) 単体だと文字が背景に沈んで読めない
|
|
51
53
|
if (!color) {
|
|
52
|
-
return "text-gray-700"
|
|
54
|
+
return "text-gray-700 dark:text-gray-300"
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
const normalized = color.toLowerCase()
|
|
56
58
|
|
|
57
59
|
if (normalized.includes("red") || normalized.includes("赤")) {
|
|
58
|
-
return "text-red-600"
|
|
60
|
+
return "text-red-600 dark:text-red-400"
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
if (normalized.includes("green") || normalized.includes("緑")) {
|
|
62
|
-
return "text-green-600"
|
|
64
|
+
return "text-green-600 dark:text-green-400"
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
if (normalized.includes("blue") || normalized.includes("青")) {
|
|
66
|
-
return "text-blue-600"
|
|
68
|
+
return "text-blue-600 dark:text-blue-400"
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
if (normalized.includes("yellow") || normalized.includes("黄")) {
|
|
70
|
-
return "text-yellow-600"
|
|
72
|
+
return "text-yellow-600 dark:text-yellow-400"
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
if (normalized.includes("orange") || normalized.includes("橙")) {
|
|
74
|
-
return "text-orange-500"
|
|
76
|
+
return "text-orange-500 dark:text-orange-400"
|
|
75
77
|
}
|
|
76
78
|
|
|
77
|
-
return "text-gray-700"
|
|
79
|
+
return "text-gray-700 dark:text-gray-300"
|
|
78
80
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the ids-stream ordering helpers (single source of truth for list order).
|
|
3
|
+
* ids ストリーム並び順ヘルパー (一覧順序の単一情報源) のテスト。
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it } from "vitest"
|
|
6
|
+
import { compareIdsStreamOrder, findIdsStreamResumeIndex, isIdsStreamOrdered, upsertInIdsStreamOrder } from "./ids-stream"
|
|
7
|
+
import type { DailyReportItem } from "./types"
|
|
8
|
+
|
|
9
|
+
const item = (reportHubId: number, businessDate: string | null): DailyReportItem => ({ reportHubId, businessDate, sourceType: "Internal" })
|
|
10
|
+
|
|
11
|
+
describe("compareIdsStreamOrder", () => {
|
|
12
|
+
it("orders businessDate DESC, then id DESC, with null dates last (SQL と同一)", () => {
|
|
13
|
+
const sorted = [item(1, "2026-08-01"), item(9, "2026-08-08"), item(3, null), item(5, "2026-08-08"), item(2, null)].sort(compareIdsStreamOrder)
|
|
14
|
+
expect(sorted.map((i) => i.reportHubId)).toEqual([9, 5, 1, 3, 2])
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe("isIdsStreamOrdered", () => {
|
|
19
|
+
it("accepts an ordered array and rejects date/id violations", () => {
|
|
20
|
+
expect(isIdsStreamOrdered([item(9, "2026-08-08"), item(5, "2026-08-08"), item(1, "2026-08-01"), item(3, null)])).toBe(true)
|
|
21
|
+
// 日付は揃っているが同日内の ID 昇順 (副キー違反)
|
|
22
|
+
expect(isIdsStreamOrdered([item(5, "2026-08-08"), item(9, "2026-08-08")])).toBe(false)
|
|
23
|
+
// 日付昇順
|
|
24
|
+
expect(isIdsStreamOrdered([item(1, "2026-08-01"), item(9, "2026-08-08")])).toBe(false)
|
|
25
|
+
expect(isIdsStreamOrdered([])).toBe(true)
|
|
26
|
+
expect(isIdsStreamOrdered([item(1, "2026-08-01")])).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe("upsertInIdsStreamOrder", () => {
|
|
31
|
+
const base = [item(9, "2026-08-08"), item(5, "2026-08-08"), item(1, "2026-08-01"), item(3, null)]
|
|
32
|
+
|
|
33
|
+
it("inserts a new item at its ordered position (same-date id DESC を維持)", () => {
|
|
34
|
+
// 同日 (08-08) 内で 9 と 5 の間に入る
|
|
35
|
+
const next = upsertInIdsStreamOrder(base, item(7, "2026-08-08"))
|
|
36
|
+
expect(next.map((i) => i.reportHubId)).toEqual([9, 7, 5, 1, 3])
|
|
37
|
+
// 元配列は不変
|
|
38
|
+
expect(base.map((i) => i.reportHubId)).toEqual([9, 5, 1, 3])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("inserts at head / before the null-date tail", () => {
|
|
42
|
+
expect(upsertInIdsStreamOrder(base, item(2, "2026-08-09")).map((i) => i.reportHubId)).toEqual([2, 9, 5, 1, 3])
|
|
43
|
+
expect(upsertInIdsStreamOrder(base, item(8, "2026-07-01")).map((i) => i.reportHubId)).toEqual([9, 5, 1, 8, 3])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("moves an existing item when its businessDate changes (単一経路の更新)", () => {
|
|
47
|
+
const next = upsertInIdsStreamOrder(base, item(1, "2026-08-08"))
|
|
48
|
+
expect(next.map((i) => i.reportHubId)).toEqual([9, 5, 1, 3])
|
|
49
|
+
expect(next[2].businessDate).toBe("2026-08-08")
|
|
50
|
+
expect(next).toHaveLength(4)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("null-date items land in id-DESC order within the tail", () => {
|
|
54
|
+
const next = upsertInIdsStreamOrder(base, item(6, null))
|
|
55
|
+
expect(next.map((i) => i.reportHubId)).toEqual([9, 5, 1, 6, 3])
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe("findIdsStreamResumeIndex", () => {
|
|
60
|
+
it("resumes strictly after the cursor", () => {
|
|
61
|
+
const items = [item(9, "2026-08-08"), item(5, "2026-08-08"), item(1, "2026-08-01")]
|
|
62
|
+
expect(findIdsStreamResumeIndex(items, { businessDate: "2026-08-08", reportHubId: 9 })).toBe(1)
|
|
63
|
+
expect(findIdsStreamResumeIndex(items, { businessDate: "2026-08-01", reportHubId: 1 })).toBe(3)
|
|
64
|
+
})
|
|
65
|
+
})
|