@aiquants/daily-report 0.4.2 → 0.6.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 +59 -0
- package/dist/client.d.mts +90 -5
- package/dist/client.d.ts +90 -5
- package/dist/client.js +4 -4
- package/dist/client.js.map +1 -1
- package/dist/client.mjs +4 -4
- package/dist/client.mjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- 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 +13 -1
- package/dist/server.d.ts +13 -1
- package/dist/server.js +6 -6
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +6 -6
- package/dist/server.mjs.map +1 -1
- package/dist/sse-schema-eXcMG-Ej.d.ts +2025 -0
- package/dist/sse-schema-rbG114od.d.mts +2025 -0
- package/dist/styles/daily-report.standalone.css +1 -1
- package/dist/{types-DAyE_3R1.d.mts → types-D1PKubyo.d.mts} +26 -26
- package/dist/{types-DAyE_3R1.d.ts → types-D1PKubyo.d.ts} +26 -26
- package/package.json +3 -3
- package/src/client/components/daily-report-detail-list.tsx +63 -51
- package/src/client/components/daily-report-list.tsx +16 -4
- package/src/client/components/daily-report-resolved-content.tsx +33 -7
- package/src/client/config-context.tsx +21 -8
- package/src/client/contexts/daily-report-action-context.tsx +54 -2
- package/src/client/hooks/use-daily-report-sse-connection.ts +1 -1
- package/src/client/hooks/use-daily-report.ts +25 -3
- package/src/server/cache.spec.ts +47 -0
- package/src/server/cache.ts +27 -0
- package/src/server/handlers.ts +4 -0
- package/src/server/service.ts +18 -1
- package/src/shared/comment-adapter.ts +3 -3
- package/src/shared/sse-schema.ts +27 -25
- package/src/shared/types.ts +26 -26
- package/dist/sse-schema-BnQQh_Cc.d.ts +0 -1986
- package/dist/sse-schema-DRNjDRDy.d.mts +0 -1986
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Plus } from "lucide-react"
|
|
1
|
+
import { Plus, RefreshCw, RotateCw } from "lucide-react"
|
|
2
2
|
import type { ReactNode } from "react"
|
|
3
3
|
import { useEffect, useMemo, useState } from "react"
|
|
4
4
|
import { useSearchParams } from "react-router"
|
|
@@ -28,7 +28,7 @@ export const DailyReportResolvedContent = ({ dailyReportItems: _initialItems, us
|
|
|
28
28
|
// 自動既読設定
|
|
29
29
|
const [autoMarkRead, setAutoMarkRead] = useState(false)
|
|
30
30
|
const { title, renderHeader, showDevControls } = useDailyReportConfig()
|
|
31
|
-
const { isSseEnabled, toggleSse, createReport, items: dailyReportItems } = useDailyReportActionContext()
|
|
31
|
+
const { isSseEnabled, toggleSse, createReport, items: dailyReportItems, refetchData, clearCacheAndRefetch } = useDailyReportActionContext()
|
|
32
32
|
const [searchParams] = useSearchParams()
|
|
33
33
|
|
|
34
34
|
// 選択状態の管理 (タブ間で共有)
|
|
@@ -37,6 +37,14 @@ export const DailyReportResolvedContent = ({ dailyReportItems: _initialItems, us
|
|
|
37
37
|
return dailyReportItems[0].reportHubId
|
|
38
38
|
})
|
|
39
39
|
|
|
40
|
+
const handleReload = async () => {
|
|
41
|
+
await refetchData()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const handleReloadClearCache = async () => {
|
|
45
|
+
await clearCacheAndRefetch()
|
|
46
|
+
}
|
|
47
|
+
|
|
40
48
|
const handleCreateReport = async () => {
|
|
41
49
|
const businessDateParam = searchParams.get("businessDate")
|
|
42
50
|
const today = new Date().toISOString().split("T")[0]
|
|
@@ -113,11 +121,29 @@ export const DailyReportResolvedContent = ({ dailyReportItems: _initialItems, us
|
|
|
113
121
|
<Plus className="h-4 w-4" />
|
|
114
122
|
</Button>
|
|
115
123
|
{showDevControls && (
|
|
116
|
-
<div className="hidden items-center space-x-1 sm:flex">
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
124
|
+
<div className="hidden items-center space-x-1.5 rounded-md border border-amber-300 bg-amber-50 px-2 py-0.5 text-xs sm:flex dark:border-amber-700/50 dark:bg-amber-950/40">
|
|
125
|
+
<span className="rounded bg-amber-200 px-1 py-0.2 font-bold font-mono text-[10px] text-amber-900 dark:bg-amber-800 dark:text-amber-100" title="開発環境限定のコントロールです">
|
|
126
|
+
DEV
|
|
127
|
+
</span>
|
|
128
|
+
<div className="flex items-center space-x-1">
|
|
129
|
+
<Switch id="sse-mode" checked={isSseEnabled} onCheckedChange={toggleSse} className="scale-75" />
|
|
130
|
+
<Label htmlFor="sse-mode" className="cursor-pointer text-slate-600 text-xs dark:text-slate-300" title="開発環境のみ: Real-time SSE 購読切り替え">
|
|
131
|
+
購読
|
|
132
|
+
</Label>
|
|
133
|
+
</div>
|
|
134
|
+
<Button variant="ghost" size="sm" onClick={handleReload} className="h-6 px-1.5 text-slate-700 text-xs hover:bg-amber-200 dark:text-slate-200 dark:hover:bg-amber-800" title="開発環境のみ: ページリロードなしでデータのみ再取得">
|
|
135
|
+
<RotateCw className="mr-1 h-3 w-3" />
|
|
136
|
+
リロード
|
|
137
|
+
</Button>
|
|
138
|
+
<Button
|
|
139
|
+
variant="ghost"
|
|
140
|
+
size="sm"
|
|
141
|
+
onClick={handleReloadClearCache}
|
|
142
|
+
className="h-6 px-1.5 text-amber-800 text-xs hover:bg-amber-200 dark:text-amber-300 dark:hover:bg-amber-800"
|
|
143
|
+
title="開発環境のみ: サーバー/DB & クライアントのキャッシュを破棄してデータ再取得">
|
|
144
|
+
<RefreshCw className="mr-1 h-3 w-3" />
|
|
145
|
+
キャッシュクリア
|
|
146
|
+
</Button>
|
|
121
147
|
</div>
|
|
122
148
|
)}
|
|
123
149
|
<Switch id="auto-read-mode" checked={autoMarkRead} onCheckedChange={handleAutoMarkReadChange} className="scale-75" />
|
|
@@ -56,12 +56,12 @@ export type DailyReportClientConfig = {
|
|
|
56
56
|
draftLabelName: string
|
|
57
57
|
/** 詳細/一覧カード of field 見出しラベル。既定は中立英語。アプリが自ドメインの表記を注入する。 */
|
|
58
58
|
fieldLabels: DailyReportFieldLabels
|
|
59
|
+
/** 外部ソース区分ごとのバッジ表示設定。 */
|
|
60
|
+
sourceTypeConfigs?: Record<string, SourceTypeConfig>
|
|
59
61
|
/** ヘッダー描画スロット。アプリのヘッダーコンポーネントを差し込む。 */
|
|
60
62
|
renderHeader: (props: DailyReportHeaderProps) => ReactNode
|
|
61
63
|
/** 開発向けコントロール (SSE 購読トグル等) を表示するか。 */
|
|
62
64
|
showDevControls: boolean
|
|
63
|
-
/** データソースごとのバッジ表示設定。 */
|
|
64
|
-
sourceTypeConfigs?: Record<string, SourceTypeConfig>
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/**
|
|
@@ -70,6 +70,7 @@ export type DailyReportClientConfig = {
|
|
|
70
70
|
*/
|
|
71
71
|
export type DailyReportClientConfigInput = Partial<Omit<DailyReportClientConfig, "fieldLabels">> & {
|
|
72
72
|
fieldLabels?: Partial<DailyReportFieldLabels>
|
|
73
|
+
sourceTypeConfigs?: Record<string, SourceTypeConfig>
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
/** 中立英語のフィールドラベル既定 (ドメイン非依存)。 */
|
|
@@ -102,6 +103,12 @@ export const defaultDailyReportClientConfig: DailyReportClientConfig = {
|
|
|
102
103
|
// 中立プレースホルダー。実運用のラベル名 (アプリ固有) は Provider / config prop で上書きする。
|
|
103
104
|
draftLabelName: "draft",
|
|
104
105
|
fieldLabels: defaultDailyReportFieldLabels,
|
|
106
|
+
sourceTypeConfigs: {
|
|
107
|
+
Internal: {
|
|
108
|
+
label: "オリジナル",
|
|
109
|
+
badgeClassName: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400",
|
|
110
|
+
},
|
|
111
|
+
},
|
|
105
112
|
renderHeader: ({ title, annotation }) => (
|
|
106
113
|
<header className="flex flex-wrap items-baseline justify-between gap-2 px-2 py-1">
|
|
107
114
|
<span className="text-nowrap text-slate-700 text-sm dark:text-slate-200">{title}</span>
|
|
@@ -109,12 +116,6 @@ export const defaultDailyReportClientConfig: DailyReportClientConfig = {
|
|
|
109
116
|
</header>
|
|
110
117
|
),
|
|
111
118
|
showDevControls: false,
|
|
112
|
-
sourceTypeConfigs: {
|
|
113
|
-
Internal: {
|
|
114
|
-
label: "オリジナル",
|
|
115
|
-
badgeClassName: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400",
|
|
116
|
-
},
|
|
117
|
-
},
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
const DailyReportConfigContext = createContext<DailyReportClientConfig>(defaultDailyReportClientConfig)
|
|
@@ -141,3 +142,15 @@ export const DailyReportConfigProvider = ({ config, children }: { config?: Daily
|
|
|
141
142
|
* 日報クライアント設定を読む (プロバイダー未設置時は既定値)。
|
|
142
143
|
*/
|
|
143
144
|
export const useDailyReportConfig = (): DailyReportClientConfig => useContext(DailyReportConfigContext)
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolves source type badge configuration with case-insensitive fallback.
|
|
148
|
+
* ケースインセンシティブなフォールバック付きでデータソースバッジ設定を解決する。
|
|
149
|
+
*/
|
|
150
|
+
export const resolveSourceTypeConfig = (sourceType?: string | null, sourceTypeConfigs?: Record<string, SourceTypeConfig>): SourceTypeConfig | undefined => {
|
|
151
|
+
if (!(sourceType && sourceTypeConfigs)) return undefined
|
|
152
|
+
if (sourceTypeConfigs[sourceType]) return sourceTypeConfigs[sourceType]
|
|
153
|
+
const lower = sourceType.toLowerCase()
|
|
154
|
+
const matchedKey = Object.keys(sourceTypeConfigs).find((k) => k.toLowerCase() === lower)
|
|
155
|
+
return matchedKey ? sourceTypeConfigs[matchedKey] : undefined
|
|
156
|
+
}
|
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
* 日報の楽観的 CRUD と SSE 同期を提供するアクションコンテキスト。
|
|
4
4
|
*/
|
|
5
5
|
import { type Context, createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react"
|
|
6
|
+
import { useRevalidator } from "react-router"
|
|
6
7
|
import { createLogger, LogLevel } from "../../shared/logger"
|
|
7
8
|
import type { DailyReportSseMessage } from "../../shared/sse-schema"
|
|
8
9
|
import type { DailyReportCommentItem, DailyReportDetail, DailyReportItem, DailyReportUser } from "../../shared/types"
|
|
9
10
|
import { useDailyReportConfig } from "../config-context"
|
|
10
|
-
import { acquireMutationLock, applyDailyReportServerUpdates, deleteDailyReportCache, getCachedReport, registerRecentDeletion, releaseMutationLock, subscribeCacheReady, updateDailyReportCache, writeCache } from "../hooks/use-daily-report"
|
|
11
|
+
import { acquireMutationLock, applyDailyReportServerUpdates, clearDailyReportCache, DailyReportCache, deleteDailyReportCache, getCachedReport, registerRecentDeletion, releaseMutationLock, subscribeCacheReady, updateDailyReportCache, writeCache } from "../hooks/use-daily-report"
|
|
11
12
|
import { useDailyReportSseConnection } from "../hooks/use-daily-report-sse-connection"
|
|
12
13
|
|
|
13
14
|
const logger = createLogger(LogLevel.INFO, "daily-report-action-context")
|
|
@@ -80,6 +81,10 @@ type DailyReportActionContextType = {
|
|
|
80
81
|
deleteReport: (reportHubId: number, businessDate: string) => Promise<void>
|
|
81
82
|
/** stale アイテムをリストから除去する安全弁 */
|
|
82
83
|
removeStaleItem: (reportHubId: number) => void
|
|
84
|
+
/** データのみリロード (ページリロードなし) */
|
|
85
|
+
refetchData: () => Promise<void>
|
|
86
|
+
/** キャッシュを破棄してデータのみ再読み込み (ページリロードなし) */
|
|
87
|
+
clearCacheAndRefetch: () => Promise<void>
|
|
83
88
|
}
|
|
84
89
|
|
|
85
90
|
// HMR / dual-bundle (ESM+CJS) でもコンテキスト identity を一意に保つため、
|
|
@@ -132,6 +137,50 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
132
137
|
/** 自分が発行したアクションのID(SSEエコー無視用) */
|
|
133
138
|
const processedClientTempIds = useRef<Set<string>>(new Set())
|
|
134
139
|
|
|
140
|
+
const revalidator = useRevalidator()
|
|
141
|
+
|
|
142
|
+
const refetchData = useCallback(async () => {
|
|
143
|
+
logger.info("[DailyReportActionProvider] Refetching data without page reload...")
|
|
144
|
+
if (revalidator.state === "idle") {
|
|
145
|
+
revalidator.revalidate()
|
|
146
|
+
}
|
|
147
|
+
setVersion((v) => v + 1)
|
|
148
|
+
DailyReportCache.listeners.forEach((listener: (id: number) => void) => {
|
|
149
|
+
listener(-1)
|
|
150
|
+
})
|
|
151
|
+
}, [revalidator])
|
|
152
|
+
|
|
153
|
+
const clearCacheAndRefetch = useCallback(async () => {
|
|
154
|
+
logger.info("[DailyReportActionProvider] Clearing client & server/DB cache and refetching data without page reload...")
|
|
155
|
+
// 1. クライアント側キャッシュ破棄
|
|
156
|
+
clearDailyReportCache()
|
|
157
|
+
removedIds.current.clear()
|
|
158
|
+
try {
|
|
159
|
+
sessionStorage.clear()
|
|
160
|
+
} catch {}
|
|
161
|
+
|
|
162
|
+
// 2. サーバー/DB側キャッシュ破棄 API 呼び出し
|
|
163
|
+
try {
|
|
164
|
+
const formData = new FormData()
|
|
165
|
+
formData.append("intent", "clearCache")
|
|
166
|
+
await fetch(`${apiBasePath}/action`, {
|
|
167
|
+
method: "POST",
|
|
168
|
+
body: formData,
|
|
169
|
+
})
|
|
170
|
+
} catch (e) {
|
|
171
|
+
logger.warn("[DailyReportActionProvider] Failed to clear server-side cache:", e)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 3. ルートローダーの再フェッチ & UI 再描画
|
|
175
|
+
if (revalidator.state === "idle") {
|
|
176
|
+
revalidator.revalidate()
|
|
177
|
+
}
|
|
178
|
+
setVersion((v) => v + 1)
|
|
179
|
+
DailyReportCache.listeners.forEach((listener: (id: number) => void) => {
|
|
180
|
+
listener(-1)
|
|
181
|
+
})
|
|
182
|
+
}, [apiBasePath, revalidator])
|
|
183
|
+
|
|
135
184
|
/**
|
|
136
185
|
* Queue for SSE messages that arrived before the target report was cached.
|
|
137
186
|
* キャッシュ未取得時に到着した SSE メッセージの待機キュー。
|
|
@@ -343,7 +392,7 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
343
392
|
|
|
344
393
|
const updatedReport = {
|
|
345
394
|
...cached,
|
|
346
|
-
commentItems: [...cached.commentItems, newComment].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()),
|
|
395
|
+
commentItems: [...cached.commentItems, newComment].sort((a, b) => new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime()),
|
|
347
396
|
}
|
|
348
397
|
applyDailyReportServerUpdates(reportHubId, updatedReport)
|
|
349
398
|
// Update mutation time to prevent stale fetch overwrite
|
|
@@ -703,6 +752,7 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
703
752
|
createdAt: now,
|
|
704
753
|
author: getUserDisplayName(user),
|
|
705
754
|
userId: user?.id ?? "unknown",
|
|
755
|
+
sourceType: "Internal",
|
|
706
756
|
employeeName: getUserDisplayName(user),
|
|
707
757
|
updatedBy: getUserDisplayName(user),
|
|
708
758
|
updatedAt: now,
|
|
@@ -903,6 +953,8 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
903
953
|
publishReport,
|
|
904
954
|
deleteReport,
|
|
905
955
|
removeStaleItem,
|
|
956
|
+
refetchData,
|
|
957
|
+
clearCacheAndRefetch,
|
|
906
958
|
}}>
|
|
907
959
|
{children}
|
|
908
960
|
</DailyReportActionContext.Provider>
|
|
@@ -53,7 +53,7 @@ export const useDailyReportSseConnection = ({ isSseEnabled, onMessage }: UseDail
|
|
|
53
53
|
const raw = JSON.parse(event.data)
|
|
54
54
|
const result = dailyReportSseMessageSchema.safeParse(raw)
|
|
55
55
|
if (!result.success) {
|
|
56
|
-
logger.error("SSE Validation Error", result.error.
|
|
56
|
+
logger.error("SSE Validation Error:", result.error.flatten(), "raw message:", raw)
|
|
57
57
|
return
|
|
58
58
|
}
|
|
59
59
|
if (result.data.type === "connected") {
|
|
@@ -27,7 +27,7 @@ const FETCH_DEBOUNCE_MS = 120
|
|
|
27
27
|
// --- Cache Store (Centralized State) ---
|
|
28
28
|
type CacheEntry<T> = { data: T; expiresAt: number }
|
|
29
29
|
|
|
30
|
-
const DailyReportCache = {
|
|
30
|
+
export const DailyReportCache = {
|
|
31
31
|
reports: new Map<number, CacheEntry<DailyReportDetail>>(),
|
|
32
32
|
lists: new Map<string, CacheEntry<DailyReportDetail[]>>(),
|
|
33
33
|
pendingLists: new Map<string, Promise<DailyReportDetail[]>>(),
|
|
@@ -58,6 +58,23 @@ const DailyReportCache = {
|
|
|
58
58
|
}
|
|
59
59
|
},
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Clear all cached entries and pending requests.
|
|
63
|
+
* すべてのキャッシュエントリおよび待機中リクエストをクリアする。
|
|
64
|
+
*/
|
|
65
|
+
clear() {
|
|
66
|
+
this.reports.clear()
|
|
67
|
+
this.lists.clear()
|
|
68
|
+
this.pendingLists.clear()
|
|
69
|
+
this.pendingReports.clear()
|
|
70
|
+
this.locks.clear()
|
|
71
|
+
this.lastMutations.clear()
|
|
72
|
+
this.recentDeletes.clear()
|
|
73
|
+
this.listeners.forEach((listener) => {
|
|
74
|
+
listener(-1)
|
|
75
|
+
})
|
|
76
|
+
},
|
|
77
|
+
|
|
61
78
|
/**
|
|
62
79
|
* Check if the cache entry is still valid.
|
|
63
80
|
* キャッシュエントリがまだ有効かを確認する。
|
|
@@ -153,11 +170,11 @@ const DailyReportCache = {
|
|
|
153
170
|
// Restore missing "my comments"
|
|
154
171
|
if (cachedReport) {
|
|
155
172
|
const serverIds = new Set(report.commentItems.map((c) => c.id))
|
|
156
|
-
const missing = cachedReport.commentItems.filter((c) => !serverIds.has(c.id) && c.isMine && Date.now() - new Date(c.createdAt).getTime() < 10000)
|
|
173
|
+
const missing = cachedReport.commentItems.filter((c) => !serverIds.has(c.id) && c.isMine && Date.now() - new Date(c.createdAt || 0).getTime() < 10000)
|
|
157
174
|
if (missing.length > 0) {
|
|
158
175
|
report = {
|
|
159
176
|
...report,
|
|
160
|
-
commentItems: [...report.commentItems, ...missing].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()),
|
|
177
|
+
commentItems: [...report.commentItems, ...missing].sort((a, b) => new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime()),
|
|
161
178
|
}
|
|
162
179
|
}
|
|
163
180
|
}
|
|
@@ -218,6 +235,11 @@ export const updateDailyReportCache = (id: number, u: Partial<DailyReportDetail>
|
|
|
218
235
|
* キャッシュからレポートを削除する。
|
|
219
236
|
*/
|
|
220
237
|
export const deleteDailyReportCache = (id: number, d: string) => DailyReportCache.delete(id, d)
|
|
238
|
+
/**
|
|
239
|
+
* Clear all daily report cache store entries.
|
|
240
|
+
* 日報の全キャッシュストアを消去する。
|
|
241
|
+
*/
|
|
242
|
+
export const clearDailyReportCache = () => DailyReportCache.clear()
|
|
221
243
|
|
|
222
244
|
export const acquireMutationLock = (id: number) => {
|
|
223
245
|
DailyReportCache.locks.add(id)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest"
|
|
2
|
+
import { createEpochStore, SqlResultCache } from "./cache"
|
|
3
|
+
|
|
4
|
+
describe("SqlResultCache Memory Cleanup", () => {
|
|
5
|
+
it("should clean up expired cache entries when map size exceeds 500", async () => {
|
|
6
|
+
// Mock epoch store
|
|
7
|
+
const epochStore = createEpochStore(undefined)
|
|
8
|
+
vi.spyOn(epochStore, "getEpoch").mockResolvedValue(0)
|
|
9
|
+
|
|
10
|
+
const cache = new SqlResultCache({ defaultTtlMs: 1000 }, epochStore)
|
|
11
|
+
|
|
12
|
+
// 1. 期限切れのキャッシュを 500 件作成 (expireAt を過去に設定)
|
|
13
|
+
// 直接プライベートフィールド buckets に流し込むことでモック状態を作る
|
|
14
|
+
const buckets = (cache as any).buckets
|
|
15
|
+
for (let i = 0; i < 505; i++) {
|
|
16
|
+
buckets.set(`expired-key-${i}`, {
|
|
17
|
+
records: [i],
|
|
18
|
+
expireAt: Date.now() - 1000, // 過去時間
|
|
19
|
+
epoch: 0,
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 2. 有効なキャッシュを 10 件作成 (expireAt を未来に設定)
|
|
24
|
+
for (let i = 0; i < 10; i++) {
|
|
25
|
+
buckets.set(`valid-key-${i}`, {
|
|
26
|
+
records: [i],
|
|
27
|
+
expireAt: Date.now() + 100000, // 未来時間
|
|
28
|
+
epoch: 0,
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
expect(buckets.size).toBe(515)
|
|
33
|
+
|
|
34
|
+
// 3. getOrFetch を実行して 500 件超えの閾値トリガーを引く
|
|
35
|
+
const fetcher = vi.fn().mockResolvedValue(["new-value"])
|
|
36
|
+
await cache.getOrFetch({
|
|
37
|
+
cacheKey: "trigger-key",
|
|
38
|
+
fetcher,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// 4. 検証: 期限切れの 505 件のデータが cleanExpired で削除され、有効な 10 件 + 新規の 1 件のみ残っていること
|
|
42
|
+
expect(buckets.size).toBe(11) // 10 (valid) + 1 (trigger-key)
|
|
43
|
+
expect(buckets.has("trigger-key")).toBe(true)
|
|
44
|
+
expect(buckets.has("valid-key-0")).toBe(true)
|
|
45
|
+
expect(buckets.has("expired-key-0")).toBe(false)
|
|
46
|
+
})
|
|
47
|
+
})
|
package/src/server/cache.ts
CHANGED
|
@@ -123,6 +123,10 @@ export class SqlResultCache {
|
|
|
123
123
|
// キャッシュ保存時に現在の epoch を記録
|
|
124
124
|
const epoch = opts.epochKey ? await this.epochStore.getEpoch(opts.epochKey) : 0
|
|
125
125
|
this.buckets.set(cacheKey, { records, expireAt, epoch })
|
|
126
|
+
// 蓄積防止のため、サイズが一定値を超えたら期限切れキャッシュを一括クリーンアップ
|
|
127
|
+
if (this.buckets.size > 500) {
|
|
128
|
+
this.cleanExpired()
|
|
129
|
+
}
|
|
126
130
|
}
|
|
127
131
|
return records
|
|
128
132
|
})()
|
|
@@ -146,6 +150,15 @@ export class SqlResultCache {
|
|
|
146
150
|
this.inFlight.delete(cacheKey)
|
|
147
151
|
}
|
|
148
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Clears all cached buckets and in-flight requests.
|
|
155
|
+
* すべてのキャッシュバケットと進行中のリクエストを全クリアします。
|
|
156
|
+
*/
|
|
157
|
+
clearAll = (): void => {
|
|
158
|
+
this.buckets.clear()
|
|
159
|
+
this.inFlight.clear()
|
|
160
|
+
}
|
|
161
|
+
|
|
149
162
|
/**
|
|
150
163
|
* Invalidates cache buckets matching a prefix.
|
|
151
164
|
* 指定されたプレフィックスに一致するキャッシュバケットを無効化します。
|
|
@@ -162,4 +175,18 @@ export class SqlResultCache {
|
|
|
162
175
|
}
|
|
163
176
|
}
|
|
164
177
|
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Cleans up all expired cache buckets to prevent memory accumulation.
|
|
181
|
+
* メモリー蓄積を防ぐため、期限切れのキャッシュバケットをすべてクリーンアップする処理。
|
|
182
|
+
*/
|
|
183
|
+
private cleanExpired(): void {
|
|
184
|
+
const now = Date.now()
|
|
185
|
+
// 期限切れのキーを削除
|
|
186
|
+
for (const [key, bucket] of this.buckets.entries()) {
|
|
187
|
+
if (bucket.expireAt <= now) {
|
|
188
|
+
this.buckets.delete(key)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
165
192
|
}
|
package/src/server/handlers.ts
CHANGED
|
@@ -317,6 +317,10 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
|
|
|
317
317
|
throw e
|
|
318
318
|
}
|
|
319
319
|
}
|
|
320
|
+
case "clearCache": {
|
|
321
|
+
await service.clearCache()
|
|
322
|
+
return jsonData({ status: "OK", intent: "clearCache" })
|
|
323
|
+
}
|
|
320
324
|
default:
|
|
321
325
|
return jsonData({ error: "Invalid intent" }, { status: 400 })
|
|
322
326
|
}
|
package/src/server/service.ts
CHANGED
|
@@ -326,6 +326,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
326
326
|
date: formatDateValue(hub.businessDate, "YYYY-MM-DD"),
|
|
327
327
|
author: row.creatorName ?? maskAuditActor(hub.createdBy) ?? "",
|
|
328
328
|
userId: hub.userId ? encodeUserId(hub.userId) : "",
|
|
329
|
+
sourceType: hub.sourceType ?? "Internal",
|
|
329
330
|
createdAt: formatDateValue(hub.createdAt, "YYYY-MM-DD HH:mm:ss"),
|
|
330
331
|
updatedAt: formatDateValue(hub.updatedAt, "YYYY-MM-DD HH:mm:ss"),
|
|
331
332
|
updatedBy: maskAuditActor(hub.updatedBy),
|
|
@@ -351,7 +352,6 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
351
352
|
userId: encodeUserId(c.userId),
|
|
352
353
|
isMine: currentUserId ? c.userId === currentUserId : false,
|
|
353
354
|
})),
|
|
354
|
-
sourceType: hub.sourceType,
|
|
355
355
|
}
|
|
356
356
|
}
|
|
357
357
|
|
|
@@ -1036,6 +1036,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1036
1036
|
createdAt: formatDateValue(hub.createdAt, "YYYY-MM-DD HH:mm:ss"),
|
|
1037
1037
|
author: userName,
|
|
1038
1038
|
userId: encodeUserId(userId),
|
|
1039
|
+
sourceType: "Internal",
|
|
1039
1040
|
employeeName: userName,
|
|
1040
1041
|
// 監査列と同様、クライアント公開時は生内部 ID を難読化する (mapHubRecord と整合)
|
|
1041
1042
|
updatedBy: encodeUserId(userId),
|
|
@@ -1279,10 +1280,26 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1279
1280
|
return fullDetail ?? null
|
|
1280
1281
|
}
|
|
1281
1282
|
|
|
1283
|
+
/**
|
|
1284
|
+
* Clears all server-side SQL result and user ID caches.
|
|
1285
|
+
* サーバー側のすべての SQL 結果キャッシュおよびユーザー ID キャッシュを全消去する。
|
|
1286
|
+
*/
|
|
1287
|
+
const clearCache = async (): Promise<void> => {
|
|
1288
|
+
sqlResultCache.clearAll()
|
|
1289
|
+
userIdCache.clear()
|
|
1290
|
+
cachedDraftLabelId = null
|
|
1291
|
+
if (epochs) {
|
|
1292
|
+
await incrementRedisEpoch(DAILY_REPORT_IDS_EPOCH_KEY)
|
|
1293
|
+
}
|
|
1294
|
+
logger.info("[DailyReportService] Server-side DB/SQL caches cleared successfully.")
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1282
1297
|
return {
|
|
1283
1298
|
// 定数 (アプリ側の互換 export 用)
|
|
1284
1299
|
streamKey: DAILY_REPORT_SSE_STREAM_KEY,
|
|
1285
1300
|
streamMaxLen: DAILY_REPORT_SSE_STREAM_MAXLEN,
|
|
1301
|
+
// キャッシュクリア
|
|
1302
|
+
clearCache,
|
|
1286
1303
|
// ユーザー解決
|
|
1287
1304
|
getUserIdByExternalId,
|
|
1288
1305
|
// 参照系
|
|
@@ -32,9 +32,9 @@ export const mergeComments = (legacyComments: DailyReportComment[], modernCommen
|
|
|
32
32
|
// 2. Modern コメントの変換
|
|
33
33
|
const modernUIComments: UIComment[] = modernComments.map((c) => ({
|
|
34
34
|
id: c.id,
|
|
35
|
-
authorName: c.userName,
|
|
36
|
-
content: c.content,
|
|
37
|
-
createdAt: c.createdAt,
|
|
35
|
+
authorName: c.userName ?? "",
|
|
36
|
+
content: c.content ?? "",
|
|
37
|
+
createdAt: c.createdAt ?? "",
|
|
38
38
|
isMine: currentUserId ? c.userId === currentUserId : c.isMine,
|
|
39
39
|
isLegacy: false,
|
|
40
40
|
}))
|
package/src/shared/sse-schema.ts
CHANGED
|
@@ -8,48 +8,49 @@ import type { DailyReportCommentItem as DeclaredComment, DailyReportDetail as De
|
|
|
8
8
|
// --- サブスキーマ(types.ts の型と構造的に一致させる) ---
|
|
9
9
|
|
|
10
10
|
export const dailyReportInterviewerSchema = z.object({
|
|
11
|
-
name: z.string(),
|
|
12
|
-
affiliation: z.string().
|
|
11
|
+
name: z.string().nullish(),
|
|
12
|
+
affiliation: z.string().nullish(),
|
|
13
13
|
})
|
|
14
14
|
|
|
15
15
|
export const dailyReportCommentSchema = z.object({
|
|
16
|
-
name: z.string().
|
|
17
|
-
text: z.string().
|
|
18
|
-
color: z.string().
|
|
16
|
+
name: z.string().nullish(),
|
|
17
|
+
text: z.string().nullish(),
|
|
18
|
+
color: z.string().nullish(),
|
|
19
19
|
})
|
|
20
20
|
|
|
21
21
|
export const dailyReportLabelDefSchema = z.object({
|
|
22
22
|
id: z.number(),
|
|
23
|
-
name: z.string(),
|
|
24
|
-
color: z.string().
|
|
23
|
+
name: z.string().nullish(),
|
|
24
|
+
color: z.string().nullish(),
|
|
25
25
|
})
|
|
26
26
|
|
|
27
27
|
export const dailyReportCommentItemSchema = z.object({
|
|
28
28
|
id: z.number(),
|
|
29
|
-
userId: z.string(),
|
|
30
|
-
userName: z.string(),
|
|
31
|
-
content: z.string(),
|
|
32
|
-
createdAt: z.string(),
|
|
29
|
+
userId: z.string().nullish(),
|
|
30
|
+
userName: z.string().nullish(),
|
|
31
|
+
content: z.string().nullish(),
|
|
32
|
+
createdAt: z.string().nullish(),
|
|
33
33
|
isMine: z.boolean(),
|
|
34
34
|
})
|
|
35
35
|
|
|
36
36
|
export const dailyReportDetailSchema = z.object({
|
|
37
37
|
reportHubId: z.number(),
|
|
38
|
-
date: z.string().
|
|
39
|
-
createdAt: z.string().
|
|
40
|
-
author: z.string(),
|
|
41
|
-
userId: z.string(),
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
38
|
+
date: z.string().nullish(),
|
|
39
|
+
createdAt: z.string().nullish(),
|
|
40
|
+
author: z.string().nullish(),
|
|
41
|
+
userId: z.string().nullish(),
|
|
42
|
+
sourceType: z.string().nullish(),
|
|
43
|
+
employeeName: z.string().nullish(),
|
|
44
|
+
updatedBy: z.string().nullish(),
|
|
45
|
+
updatedAt: z.string().nullish(),
|
|
46
|
+
category: z.string().nullish(),
|
|
47
|
+
creationCategory: z.string().nullish(),
|
|
48
|
+
visitTimeFrom: z.string().nullish(),
|
|
49
|
+
visitTimeTo: z.string().nullish(),
|
|
50
|
+
customerName: z.string().nullish(),
|
|
50
51
|
interviewers: z.array(dailyReportInterviewerSchema),
|
|
51
|
-
subject: z.string().
|
|
52
|
-
content: z.string().
|
|
52
|
+
subject: z.string().nullish(),
|
|
53
|
+
content: z.string().nullish(),
|
|
53
54
|
comments: z.array(dailyReportCommentSchema),
|
|
54
55
|
isRead: z.boolean(),
|
|
55
56
|
isStarred: z.boolean(),
|
|
@@ -110,6 +111,7 @@ export const reportPublishMessageSchema = z.object({
|
|
|
110
111
|
reportHubId: z.number(),
|
|
111
112
|
report: dailyReportDetailSchema,
|
|
112
113
|
clientTempId: z.string(),
|
|
114
|
+
recipientRawUserId: z.number().optional(),
|
|
113
115
|
})
|
|
114
116
|
|
|
115
117
|
export const reportDeleteMessageSchema = z.object({
|
package/src/shared/types.ts
CHANGED
|
@@ -12,58 +12,58 @@ export type DailyReportItem = {
|
|
|
12
12
|
|
|
13
13
|
/** 面談者情報。 */
|
|
14
14
|
export type DailyReportInterviewer = {
|
|
15
|
-
name
|
|
16
|
-
affiliation
|
|
15
|
+
name?: string | null
|
|
16
|
+
affiliation?: string | null
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/** レガシー形式 (JSON 埋め込み) のコメント。 */
|
|
20
20
|
export type DailyReportComment = {
|
|
21
|
-
name
|
|
22
|
-
text
|
|
23
|
-
color
|
|
21
|
+
name?: string | null
|
|
22
|
+
text?: string | null
|
|
23
|
+
color?: string | null
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/** ラベル定義。 */
|
|
27
27
|
export type DailyReportLabelDef = {
|
|
28
28
|
id: number
|
|
29
|
-
name
|
|
30
|
-
color
|
|
29
|
+
name?: string | null
|
|
30
|
+
color?: string | null
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/** リレーショナルコメント 1 件。`userId` は難読化済み文字列。 */
|
|
34
34
|
export type DailyReportCommentItem = {
|
|
35
35
|
id: number
|
|
36
|
-
userId
|
|
37
|
-
userName
|
|
38
|
-
content
|
|
39
|
-
createdAt
|
|
36
|
+
userId?: string | null
|
|
37
|
+
userName?: string | null
|
|
38
|
+
content?: string | null
|
|
39
|
+
createdAt?: string | null
|
|
40
40
|
isMine: boolean
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/** 日報詳細 (一覧アイテムの遅延解決結果)。`userId` は難読化済み文字列。 */
|
|
44
44
|
export type DailyReportDetail = {
|
|
45
45
|
reportHubId: number
|
|
46
|
-
date
|
|
47
|
-
createdAt
|
|
48
|
-
author
|
|
49
|
-
userId
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
46
|
+
date?: string | null
|
|
47
|
+
createdAt?: string | null
|
|
48
|
+
author?: string | null
|
|
49
|
+
userId?: string | null
|
|
50
|
+
sourceType?: string | null
|
|
51
|
+
employeeName?: string | null
|
|
52
|
+
updatedBy?: string | null
|
|
53
|
+
updatedAt?: string | null
|
|
54
|
+
category?: string | null
|
|
55
|
+
creationCategory?: string | null
|
|
56
|
+
visitTimeFrom?: string | null
|
|
57
|
+
visitTimeTo?: string | null
|
|
58
|
+
customerName?: string | null
|
|
58
59
|
interviewers: DailyReportInterviewer[]
|
|
59
|
-
subject
|
|
60
|
-
content
|
|
60
|
+
subject?: string | null
|
|
61
|
+
content?: string | null
|
|
61
62
|
comments: DailyReportComment[]
|
|
62
63
|
isRead: boolean
|
|
63
64
|
isStarred: boolean
|
|
64
65
|
labels: DailyReportLabelDef[]
|
|
65
66
|
commentItems: DailyReportCommentItem[]
|
|
66
|
-
sourceType?: string
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/**
|