@aiquants/daily-report 0.6.0 → 0.6.2
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 +38 -38
- package/dist/client.d.mts +9 -3
- package/dist/client.d.ts +9 -3
- 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/server.d.mts +34 -4
- package/dist/server.d.ts +34 -4
- 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/package.json +3 -3
- package/src/client/components/daily-report-detail-list.tsx +28 -6
- package/src/client/components/daily-report-list.tsx +25 -4
- package/src/client/components/daily-report-resolved-content.tsx +28 -4
- package/src/client/config-context.tsx +2 -0
- package/src/client/contexts/daily-report-action-context.tsx +5 -1
- package/src/client/route-helpers.ts +8 -2
- package/src/server/authz.spec.ts +57 -0
- package/src/server/authz.ts +99 -0
- package/src/server/handlers.ts +5 -4
- package/src/server/service.ts +31 -13
- package/src/server.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiquants/daily-report",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "Reusable daily-report feature package: shared types/schemas, React (Router v7) UI with virtual scrolling + optimistic updates + SSE sync, and a drizzle (mssql) server layer with DI ports (auth, user resolution, redis, external sources).",
|
|
5
5
|
"sideEffects": [
|
|
6
6
|
"**/*.css"
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"vitest": "^4.1.8",
|
|
74
74
|
"zod": "^3.25.76",
|
|
75
75
|
"@aiquants/swipe-overlay": "1.2.4",
|
|
76
|
-
"@aiquants/virtualscroll": "1.19.
|
|
76
|
+
"@aiquants/virtualscroll": "1.19.3"
|
|
77
77
|
},
|
|
78
78
|
"peerDependencies": {
|
|
79
79
|
"drizzle-orm": ">=1.0.0-beta.4 <2.0.0",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"react-router": "^7.0.0",
|
|
83
83
|
"zod": ">=3.25.0 <5.0.0",
|
|
84
84
|
"@aiquants/swipe-overlay": "^1.2.4",
|
|
85
|
-
"@aiquants/virtualscroll": "^1.19.
|
|
85
|
+
"@aiquants/virtualscroll": "^1.19.3"
|
|
86
86
|
},
|
|
87
87
|
"peerDependenciesMeta": {
|
|
88
88
|
"drizzle-orm": {
|
|
@@ -78,13 +78,15 @@ type DailyReportDetailListProps = {
|
|
|
78
78
|
userId?: string | null
|
|
79
79
|
selectedItemId?: number | null
|
|
80
80
|
onSelectItem?: (id: number | null) => void
|
|
81
|
+
initialScrollOffset?: number
|
|
82
|
+
onScrollOffsetChange?: (offset: number) => void
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
/**
|
|
84
86
|
* Daily report detail cards rendered with dynamic height virtual scroll.
|
|
85
87
|
* 動的高さの仮想スクロールで日報詳細カードを描画するコンポーネント。
|
|
86
88
|
*/
|
|
87
|
-
export const DailyReportDetailList = ({ dailyReportItems, userId, selectedItemId, onSelectItem }: DailyReportDetailListProps) => {
|
|
89
|
+
export const DailyReportDetailList = ({ dailyReportItems, userId, selectedItemId, onSelectItem, initialScrollOffset, onScrollOffsetChange }: DailyReportDetailListProps) => {
|
|
88
90
|
const [itemHeights, setItemHeights] = useState<Map<number, number>>(new Map())
|
|
89
91
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
|
90
92
|
const virtualScrollRef = useRef<VirtualScrollHandle>(null)
|
|
@@ -182,6 +184,10 @@ export const DailyReportDetailList = ({ dailyReportItems, userId, selectedItemId
|
|
|
182
184
|
)
|
|
183
185
|
|
|
184
186
|
const handleScroll = useCallback(() => {
|
|
187
|
+
if (virtualScrollRef.current && onScrollOffsetChange) {
|
|
188
|
+
onScrollOffsetChange(virtualScrollRef.current.getScrollPosition())
|
|
189
|
+
}
|
|
190
|
+
|
|
185
191
|
const container = scrollContainerRef.current
|
|
186
192
|
if (!container) return
|
|
187
193
|
|
|
@@ -199,7 +205,15 @@ export const DailyReportDetailList = ({ dailyReportItems, userId, selectedItemId
|
|
|
199
205
|
}
|
|
200
206
|
scrollEndTimerRef.current = null
|
|
201
207
|
}, 150)
|
|
202
|
-
}, [])
|
|
208
|
+
}, [onScrollOffsetChange])
|
|
209
|
+
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
return () => {
|
|
212
|
+
if (virtualScrollRef.current && onScrollOffsetChange) {
|
|
213
|
+
onScrollOffsetChange(virtualScrollRef.current.getScrollPosition())
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}, [onScrollOffsetChange])
|
|
203
217
|
|
|
204
218
|
const renderThumbOverlay = useCallback((props: ScrollBarThumbOverlayRenderProps) => <BusinessDayThumbOverlay {...props} label={overlayLabel} isWheelScrollActive={isWheelScrollActive} />, [overlayLabel, isWheelScrollActive])
|
|
205
219
|
|
|
@@ -294,6 +308,7 @@ export const DailyReportDetailList = ({ dailyReportItems, userId, selectedItemId
|
|
|
294
308
|
getItemKey={getItemKey}
|
|
295
309
|
getItemHeight={getItemHeight}
|
|
296
310
|
viewportSize={viewportHeight}
|
|
311
|
+
initialScrollOffset={initialScrollOffset}
|
|
297
312
|
overscanCount={VIRTUAL_SCROLL_OVERSCAN_COUNT}
|
|
298
313
|
scrollBarOptions={{
|
|
299
314
|
width: 8,
|
|
@@ -563,7 +578,12 @@ const DailyReportResolvedItem = ({ item, report, onCommentAdded, userId, onDoubl
|
|
|
563
578
|
// 表示中ユーザーはアクションコンテキストから取得する (root loader 非依存)
|
|
564
579
|
const { toggleStar, toggleRead, pendingStarUpdates, pendingReadUpdates, deleteReport, user } = useDailyReportActionContext()
|
|
565
580
|
const { uiComments, handleAddComment, handleDeleteComment, pendingDeleteId, setPendingDeleteId, resolvedIdMap } = useDailyReportComments(report, user, userId)
|
|
566
|
-
const { fieldLabels } = useDailyReportConfig()
|
|
581
|
+
const { fieldLabels, sourceTypeConfigs } = useDailyReportConfig()
|
|
582
|
+
|
|
583
|
+
const sourceType = item.sourceType ?? report?.sourceType
|
|
584
|
+
const sourceConfig = resolveSourceTypeConfig(sourceType, sourceTypeConfigs)
|
|
585
|
+
const isExternalSource = sourceType ? sourceType.toLowerCase() !== "internal" : false
|
|
586
|
+
const canComment = sourceConfig?.allowComment ?? !isExternalSource
|
|
567
587
|
|
|
568
588
|
const handleAddCommentWrapper = async (content: string) => {
|
|
569
589
|
await handleAddComment(content)
|
|
@@ -667,9 +687,11 @@ const DailyReportResolvedItem = ({ item, report, onCommentAdded, userId, onDoubl
|
|
|
667
687
|
/>
|
|
668
688
|
</div>
|
|
669
689
|
)}
|
|
670
|
-
|
|
671
|
-
<
|
|
672
|
-
|
|
690
|
+
{canComment && (
|
|
691
|
+
<div className="mt-auto" ref={formContainerRef}>
|
|
692
|
+
<DailyReportCommentForm reportHubId={report.reportHubId} businessDate={report.date ?? ""} onAddComment={handleAddCommentWrapper} />
|
|
693
|
+
</div>
|
|
694
|
+
)}
|
|
673
695
|
</div>
|
|
674
696
|
<MetadataPanel report={report} visitTimeDisplay={visitTimeDisplay} categoryLabel={categoryLabel} fieldLabels={fieldLabels} />
|
|
675
697
|
</div>
|
|
@@ -47,6 +47,8 @@ type DailyReportListProps = {
|
|
|
47
47
|
selectedReportHubId: number | null
|
|
48
48
|
onSelectItem: (reportHubId: number | null) => void
|
|
49
49
|
userId?: string | null
|
|
50
|
+
initialScrollOffset?: number
|
|
51
|
+
onScrollOffsetChange?: (offset: number) => void
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
/**
|
|
@@ -228,7 +230,7 @@ const useAutoRead = (report: DailyReportDetail | null, isLoading: boolean, autoM
|
|
|
228
230
|
* Daily report list cards with key metadata and preview content via virtual scrolling.
|
|
229
231
|
* 主なメタデータと内容プレビューを仮想スクロール表示する日報リスト。
|
|
230
232
|
*/
|
|
231
|
-
export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate = null, initialSelectedReportHubId = null, autoMarkRead = false, selectedReportHubId, onSelectItem, userId }: DailyReportListProps) => {
|
|
233
|
+
export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate = null, initialSelectedReportHubId = null, autoMarkRead = false, selectedReportHubId, onSelectItem, userId, initialScrollOffset, onScrollOffsetChange }: DailyReportListProps) => {
|
|
232
234
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
|
233
235
|
const virtualScrollRef = useRef<VirtualScrollHandle>(null)
|
|
234
236
|
const { viewportHeight, updateViewportHeight } = useDynamicViewportHeight(scrollContainerRef)
|
|
@@ -400,6 +402,10 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
|
|
|
400
402
|
)
|
|
401
403
|
|
|
402
404
|
const handleScroll = useCallback(() => {
|
|
405
|
+
if (virtualScrollRef.current && onScrollOffsetChange) {
|
|
406
|
+
onScrollOffsetChange(virtualScrollRef.current.getScrollPosition())
|
|
407
|
+
}
|
|
408
|
+
|
|
403
409
|
const container = scrollContainerRef.current
|
|
404
410
|
if (!container) return
|
|
405
411
|
|
|
@@ -417,7 +423,15 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
|
|
|
417
423
|
}
|
|
418
424
|
scrollEndTimerRef.current = null
|
|
419
425
|
}, 150)
|
|
420
|
-
}, [])
|
|
426
|
+
}, [onScrollOffsetChange])
|
|
427
|
+
|
|
428
|
+
useEffect(() => {
|
|
429
|
+
return () => {
|
|
430
|
+
if (virtualScrollRef.current && onScrollOffsetChange) {
|
|
431
|
+
onScrollOffsetChange(virtualScrollRef.current.getScrollPosition())
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}, [onScrollOffsetChange])
|
|
421
435
|
|
|
422
436
|
const renderThumbOverlay = useCallback((props: ScrollBarThumbOverlayRenderProps) => <BusinessDayThumbOverlay {...props} label={overlayLabel} isWheelScrollActive={isWheelScrollActive} />, [overlayLabel, isWheelScrollActive])
|
|
423
437
|
|
|
@@ -431,6 +445,7 @@ export const DailyReportList = ({ dailyReportItems, initialSelectedBusinessDate
|
|
|
431
445
|
getItem={getItem}
|
|
432
446
|
getItemHeight={getItemHeight}
|
|
433
447
|
viewportSize={viewportHeight}
|
|
448
|
+
initialScrollOffset={initialScrollOffset}
|
|
434
449
|
overscanCount={VIRTUAL_SCROLL_OVERSCAN_COUNT}
|
|
435
450
|
scrollBarOptions={{
|
|
436
451
|
width: SCROLL_BAR_WIDTH,
|
|
@@ -648,6 +663,7 @@ const DailyReportDisplayPane = ({
|
|
|
648
663
|
report,
|
|
649
664
|
isLoading,
|
|
650
665
|
error,
|
|
666
|
+
selectedItem,
|
|
651
667
|
autoMarkRead = false,
|
|
652
668
|
isActive = true,
|
|
653
669
|
onCommentAdded,
|
|
@@ -666,12 +682,17 @@ const DailyReportDisplayPane = ({
|
|
|
666
682
|
}) => {
|
|
667
683
|
// 表示中ユーザーはアクションコンテキストから取得する (root loader 非依存)
|
|
668
684
|
const { toggleStar, toggleRead, deleteReport, user } = useDailyReportActionContext()
|
|
669
|
-
const { fieldLabels } = useDailyReportConfig()
|
|
685
|
+
const { fieldLabels, sourceTypeConfigs } = useDailyReportConfig()
|
|
670
686
|
|
|
671
687
|
// Use custom hook for comments
|
|
672
688
|
const { uiComments, handleAddComment, handleDeleteComment, pendingDeleteId, setPendingDeleteId, resolvedIdMap } = useDailyReportComments(report, user, userId)
|
|
673
689
|
const { handleToggleRead } = useAutoRead(report, isLoading, autoMarkRead, isActive, toggleRead)
|
|
674
690
|
|
|
691
|
+
const sourceType = selectedItem.sourceType ?? report?.sourceType
|
|
692
|
+
const sourceConfig = resolveSourceTypeConfig(sourceType, sourceTypeConfigs)
|
|
693
|
+
const isExternalSource = sourceType ? sourceType.toLowerCase() !== "internal" : false
|
|
694
|
+
const canComment = sourceConfig?.allowComment ?? !isExternalSource
|
|
695
|
+
|
|
675
696
|
// Auto-scroll to bottom when comments are added (e.g. via SSE)
|
|
676
697
|
const prevReportHubIdRef = useRef<number | null>(null)
|
|
677
698
|
const prevCommentsLengthRef = useRef(uiComments.length)
|
|
@@ -804,7 +825,7 @@ const DailyReportDisplayPane = ({
|
|
|
804
825
|
resolvedIdMap={resolvedIdMap}
|
|
805
826
|
/>
|
|
806
827
|
)}
|
|
807
|
-
<DailyReportCommentForm reportHubId={report.reportHubId} businessDate={report.date ?? ""} onAddComment={handleAddCommentWrapper} />
|
|
828
|
+
{canComment && <DailyReportCommentForm reportHubId={report.reportHubId} businessDate={report.date ?? ""} onAddComment={handleAddCommentWrapper} />}
|
|
808
829
|
</div>
|
|
809
830
|
)}
|
|
810
831
|
</div>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Plus, RefreshCw, RotateCw } from "lucide-react"
|
|
2
2
|
import type { ReactNode } from "react"
|
|
3
|
-
import { useEffect, useMemo, useState } from "react"
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
4
4
|
import { useSearchParams } from "react-router"
|
|
5
5
|
import type { DailyReportItem } from "../../shared/types"
|
|
6
6
|
import { useDailyReportConfig } from "../config-context"
|
|
@@ -31,6 +31,18 @@ export const DailyReportResolvedContent = ({ dailyReportItems: _initialItems, us
|
|
|
31
31
|
const { isSseEnabled, toggleSse, createReport, items: dailyReportItems, refetchData, clearCacheAndRefetch } = useDailyReportActionContext()
|
|
32
32
|
const [searchParams] = useSearchParams()
|
|
33
33
|
|
|
34
|
+
// 各タブのスクロール位置をアンマウント後も保持する Ref
|
|
35
|
+
const listScrollOffsetRef = useRef<number>(0)
|
|
36
|
+
const detailListScrollOffsetRef = useRef<number>(0)
|
|
37
|
+
|
|
38
|
+
const handleListScrollOffsetChange = useCallback((offset: number) => {
|
|
39
|
+
listScrollOffsetRef.current = offset
|
|
40
|
+
}, [])
|
|
41
|
+
|
|
42
|
+
const handleDetailListScrollOffsetChange = useCallback((offset: number) => {
|
|
43
|
+
detailListScrollOffsetRef.current = offset
|
|
44
|
+
}, [])
|
|
45
|
+
|
|
34
46
|
// 選択状態の管理 (タブ間で共有)
|
|
35
47
|
const [selectedItemId, setSelectedItemId] = useState<number | null>(() => {
|
|
36
48
|
if (dailyReportItems.length === 0) return null
|
|
@@ -78,7 +90,17 @@ export const DailyReportResolvedContent = ({ dailyReportItems: _initialItems, us
|
|
|
78
90
|
{
|
|
79
91
|
value: "list",
|
|
80
92
|
label: "📄 List",
|
|
81
|
-
content:
|
|
93
|
+
content: (
|
|
94
|
+
<DailyReportList
|
|
95
|
+
dailyReportItems={dailyReportItems}
|
|
96
|
+
autoMarkRead={autoMarkRead}
|
|
97
|
+
selectedReportHubId={selectedItemId}
|
|
98
|
+
onSelectItem={setSelectedItemId}
|
|
99
|
+
userId={userId}
|
|
100
|
+
initialScrollOffset={listScrollOffsetRef.current}
|
|
101
|
+
onScrollOffsetChange={handleListScrollOffsetChange}
|
|
102
|
+
/>
|
|
103
|
+
),
|
|
82
104
|
},
|
|
83
105
|
{
|
|
84
106
|
value: "tree",
|
|
@@ -88,10 +110,12 @@ export const DailyReportResolvedContent = ({ dailyReportItems: _initialItems, us
|
|
|
88
110
|
{
|
|
89
111
|
value: "detailList",
|
|
90
112
|
label: "📋 DetailList",
|
|
91
|
-
content:
|
|
113
|
+
content: (
|
|
114
|
+
<DailyReportDetailList dailyReportItems={dailyReportItems} userId={userId} selectedItemId={selectedItemId} onSelectItem={setSelectedItemId} initialScrollOffset={detailListScrollOffsetRef.current} onScrollOffsetChange={handleDetailListScrollOffsetChange} />
|
|
115
|
+
),
|
|
92
116
|
},
|
|
93
117
|
],
|
|
94
|
-
[dailyReportItems, autoMarkRead, selectedItemId, userId],
|
|
118
|
+
[dailyReportItems, autoMarkRead, selectedItemId, userId, handleListScrollOffsetChange, handleDetailListScrollOffsetChange],
|
|
95
119
|
)
|
|
96
120
|
|
|
97
121
|
return (
|
|
@@ -163,10 +163,14 @@ export const DailyReportActionProvider = ({ children, user, initialItems = [], u
|
|
|
163
163
|
try {
|
|
164
164
|
const formData = new FormData()
|
|
165
165
|
formData.append("intent", "clearCache")
|
|
166
|
-
await fetch(`${apiBasePath}/action`, {
|
|
166
|
+
const res = await fetch(`${apiBasePath}/action`, {
|
|
167
167
|
method: "POST",
|
|
168
168
|
body: formData,
|
|
169
169
|
})
|
|
170
|
+
if (!res.ok) {
|
|
171
|
+
const errData = await res.json().catch(() => null)
|
|
172
|
+
logger.warn("[DailyReportActionProvider] Failed to clear server-side cache:", res.status, errData)
|
|
173
|
+
}
|
|
170
174
|
} catch (e) {
|
|
171
175
|
logger.warn("[DailyReportActionProvider] Failed to clear server-side cache:", e)
|
|
172
176
|
}
|
|
@@ -35,8 +35,13 @@ export const dailyReportShouldRevalidate: ShouldRevalidateFunction = ({ actionRe
|
|
|
35
35
|
* Fetches the deferred daily report id list from the API endpoint.
|
|
36
36
|
* API エンドポイントから日報 ID 一覧を遅延取得する処理。
|
|
37
37
|
*/
|
|
38
|
-
export const fetchDailyReportIds = (apiBasePath: string = defaultDailyReportClientConfig.apiBasePath): Promise<DailyReportItem[]> =>
|
|
39
|
-
|
|
38
|
+
export const fetchDailyReportIds = (apiBasePath: string = defaultDailyReportClientConfig.apiBasePath, forceRefresh = false): Promise<DailyReportItem[]> => {
|
|
39
|
+
const url = new URL(`${apiBasePath}/ids`, typeof window !== "undefined" ? window.location.origin : "http://localhost")
|
|
40
|
+
if (forceRefresh) {
|
|
41
|
+
url.searchParams.set("forceRefresh", "true")
|
|
42
|
+
url.searchParams.set("_t", Date.now().toString())
|
|
43
|
+
}
|
|
44
|
+
return fetch(url.toString(), {
|
|
40
45
|
method: "GET",
|
|
41
46
|
headers: { "Content-Type": "application/json" },
|
|
42
47
|
cache: "no-cache",
|
|
@@ -54,6 +59,7 @@ export const fetchDailyReportIds = (apiBasePath: string = defaultDailyReportClie
|
|
|
54
59
|
}
|
|
55
60
|
return data.ids as DailyReportItem[]
|
|
56
61
|
})
|
|
62
|
+
}
|
|
57
63
|
|
|
58
64
|
/**
|
|
59
65
|
* Creates a React Router clientLoader that merges server data with deferred report ids.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for defineDailyReportAuthzResources & seedDailyReportAuthzResources in @aiquants/daily-report/server.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it } from "vitest"
|
|
5
|
+
import { defineDailyReportAuthzResources, seedDailyReportAuthzResources } from "./authz"
|
|
6
|
+
|
|
7
|
+
describe("defineDailyReportAuthzResources", () => {
|
|
8
|
+
it("generates neutral default resources when no sources specified", () => {
|
|
9
|
+
const res = defineDailyReportAuthzResources()
|
|
10
|
+
expect(res).toHaveLength(4) // internal + internal_comment + external + external_comment
|
|
11
|
+
expect(res[0].resourceKey).toBe("daily_report_internal")
|
|
12
|
+
expect(res[1].resourceKey).toBe("daily_report_internal_comment")
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("generates custom resources from source inputs without domain hardcoding", () => {
|
|
16
|
+
const res = defineDailyReportAuthzResources([
|
|
17
|
+
{ key: "ORG", name: "Original", description: "Original daily reports" },
|
|
18
|
+
{ key: "NI", name: "NI", description: "NI daily reports", includeCommentResource: false },
|
|
19
|
+
])
|
|
20
|
+
expect(res).toHaveLength(3) // ORG + ORG_comment + NI (no comment)
|
|
21
|
+
expect(res[0].resourceKey).toBe("daily_report_org")
|
|
22
|
+
expect(res[1].resourceKey).toBe("daily_report_org_comment")
|
|
23
|
+
expect(res[2].resourceKey).toBe("daily_report_ni")
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe("seedDailyReportAuthzResources", () => {
|
|
28
|
+
it("idempotently seeds resources into TMResource table", async () => {
|
|
29
|
+
const seeded: Record<string, unknown>[] = []
|
|
30
|
+
|
|
31
|
+
const fakeDb = {
|
|
32
|
+
select: () => ({
|
|
33
|
+
from: () => ({
|
|
34
|
+
where: async () => [],
|
|
35
|
+
}),
|
|
36
|
+
}),
|
|
37
|
+
insert: () => ({
|
|
38
|
+
values: async (v: unknown) => {
|
|
39
|
+
seeded.push(v as Record<string, unknown>)
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const fakeAuthzTables = {
|
|
45
|
+
TMResource: { appKey: "appKey", resourceKey: "resourceKey" },
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await seedDailyReportAuthzResources(fakeDb, fakeAuthzTables, {
|
|
49
|
+
appKey: "quants",
|
|
50
|
+
sources: [{ key: "sales", name: "Sales" }],
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
expect(seeded.length).toBe(2)
|
|
54
|
+
expect(seeded[0].appKey).toBe("quants")
|
|
55
|
+
expect(seeded[0].resourceKey).toBe("daily_report_sales")
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authz helper utilities for @aiquants/daily-report: standard resource definitions & auto-seeding.
|
|
3
|
+
* @aiquants/daily-report 用の認可ヘルパー。標準リソース定義および自動シード機能を提供。
|
|
4
|
+
*/
|
|
5
|
+
import { and, eq } from "drizzle-orm"
|
|
6
|
+
|
|
7
|
+
export type AuthzResourceItem = {
|
|
8
|
+
resourceKey: string
|
|
9
|
+
name: string
|
|
10
|
+
description?: string | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type DailyReportSourceTypeInput = {
|
|
14
|
+
key: string
|
|
15
|
+
name: string
|
|
16
|
+
description?: string | null
|
|
17
|
+
includeCommentResource?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Dynamically builds neutral Authz resource definitions from provided source type inputs.
|
|
22
|
+
* 指定されたソース種別定義から中立な認可リソース定義リストを生成するファクトリ。
|
|
23
|
+
*/
|
|
24
|
+
export function defineDailyReportAuthzResources(sources?: DailyReportSourceTypeInput[]): AuthzResourceItem[] {
|
|
25
|
+
const list: AuthzResourceItem[] = []
|
|
26
|
+
const targetSources =
|
|
27
|
+
sources && sources.length > 0
|
|
28
|
+
? sources
|
|
29
|
+
: [
|
|
30
|
+
{ key: "internal", name: "Internal", description: "Internal daily reports" },
|
|
31
|
+
{ key: "external", name: "External", description: "External daily reports" },
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
for (const src of targetSources) {
|
|
35
|
+
const keyLower = src.key.toLowerCase().replace(/[^a-z0-9_]/g, "_")
|
|
36
|
+
const resourceKey = `daily_report_${keyLower}`
|
|
37
|
+
list.push({
|
|
38
|
+
resourceKey,
|
|
39
|
+
name: src.name,
|
|
40
|
+
description: src.description ?? `${src.name} daily report access`,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
if (src.includeCommentResource !== false) {
|
|
44
|
+
list.push({
|
|
45
|
+
resourceKey: `${resourceKey}_comment`,
|
|
46
|
+
name: `${src.name} Comment`,
|
|
47
|
+
description: `${src.name} daily report comment access`,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return list
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type DbSelectChain = {
|
|
56
|
+
from(t: unknown): DbSelectChain
|
|
57
|
+
where(cond: unknown): PromiseLike<unknown[]>
|
|
58
|
+
}
|
|
59
|
+
type DbInsertChain = {
|
|
60
|
+
values(v: unknown): PromiseLike<unknown>
|
|
61
|
+
}
|
|
62
|
+
type LooseDb = {
|
|
63
|
+
select(fields?: unknown): DbSelectChain
|
|
64
|
+
insert(t: unknown): DbInsertChain
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Ensures all daily-report standard resources exist in the authz TMResource table idempotently.
|
|
69
|
+
* authz データベース内に日報機能の標準認可リソースが存在することを自動保証(冪等シード)する処理。
|
|
70
|
+
*/
|
|
71
|
+
export async function seedDailyReportAuthzResources(db: unknown, authzTables: { TMResource: unknown }, opts: { appKey: string; actor?: string; sources?: DailyReportSourceTypeInput[]; resources?: AuthzResourceItem[] }): Promise<void> {
|
|
72
|
+
const d = db as LooseDb
|
|
73
|
+
const TMResource = authzTables.TMResource as Record<string, unknown>
|
|
74
|
+
const appKey = opts.appKey
|
|
75
|
+
const actor = opts.actor ?? "system:daily-report"
|
|
76
|
+
const now = new Date()
|
|
77
|
+
|
|
78
|
+
const resourceList = opts.resources ?? defineDailyReportAuthzResources(opts.sources)
|
|
79
|
+
|
|
80
|
+
for (const res of resourceList) {
|
|
81
|
+
const existing = await d
|
|
82
|
+
.select()
|
|
83
|
+
.from(TMResource)
|
|
84
|
+
.where(and(eq(TMResource.appKey as never, appKey as never), eq(TMResource.resourceKey as never, res.resourceKey as never)))
|
|
85
|
+
|
|
86
|
+
if (!existing || existing.length === 0) {
|
|
87
|
+
await d.insert(TMResource).values({
|
|
88
|
+
appKey,
|
|
89
|
+
resourceKey: res.resourceKey,
|
|
90
|
+
name: res.name,
|
|
91
|
+
description: res.description ?? null,
|
|
92
|
+
createdAt: now,
|
|
93
|
+
createdBy: actor,
|
|
94
|
+
updatedAt: now,
|
|
95
|
+
updatedBy: actor,
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/server/handlers.ts
CHANGED
|
@@ -190,6 +190,11 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
|
|
|
190
190
|
const operationTimestamp = Number(formData.get("operationTimestamp"))
|
|
191
191
|
const clientTempId = formData.get("clientTempId") as string | null
|
|
192
192
|
|
|
193
|
+
if (intent === "clearCache") {
|
|
194
|
+
await service.clearCache()
|
|
195
|
+
return jsonData({ status: "OK", intent: "clearCache" })
|
|
196
|
+
}
|
|
197
|
+
|
|
193
198
|
if (!clientTempId) {
|
|
194
199
|
return jsonData({ error: "clientTempId required" }, { status: 400 })
|
|
195
200
|
}
|
|
@@ -317,10 +322,6 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
|
|
|
317
322
|
throw e
|
|
318
323
|
}
|
|
319
324
|
}
|
|
320
|
-
case "clearCache": {
|
|
321
|
-
await service.clearCache()
|
|
322
|
-
return jsonData({ status: "OK", intent: "clearCache" })
|
|
323
|
-
}
|
|
324
325
|
default:
|
|
325
326
|
return jsonData({ error: "Invalid intent" }, { status: 400 })
|
|
326
327
|
}
|
package/src/server/service.ts
CHANGED
|
@@ -130,10 +130,11 @@ export type DailyReportServiceConfig = {
|
|
|
130
130
|
/** クロスワーカー epoch ストア (facade が生成して注入)。 */
|
|
131
131
|
epochs: EpochStore
|
|
132
132
|
/**
|
|
133
|
-
* 下書きラベル名 (
|
|
134
|
-
*
|
|
133
|
+
* 下書きラベル名 (単一または配列で指定可能)。
|
|
134
|
+
* 消費アプリ側で自 DB のラベル名や作成区分の候補名 ("下書き", "DRAFT" など) を注入できる。
|
|
135
135
|
*/
|
|
136
|
-
draftLabelName
|
|
136
|
+
draftLabelName?: string
|
|
137
|
+
draftLabelNames?: string[]
|
|
137
138
|
/** SSE Redis Stream キー (既定 "daily-report:sse-stream")。 */
|
|
138
139
|
streamKey?: string
|
|
139
140
|
/** SSE Stream の MAXLEN (既定 10000)。 */
|
|
@@ -159,7 +160,8 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
159
160
|
const { db, tables, userTable: users, resolveUserId, encodeUserId, redis, cache: sqlResultCache, epochs } = config
|
|
160
161
|
const { hub: DailyReportHub, internal: DailyReportInternal, comment: DailyReportCommentModel, label: DailyReportLabel, hubLabel: DailyReportHub_Label, userStatus: DailyReportUserStatus } = tables
|
|
161
162
|
const externalSources = config.externalSources ?? []
|
|
162
|
-
const
|
|
163
|
+
const draftLabelNames = config.draftLabelNames && config.draftLabelNames.length > 0 ? config.draftLabelNames : [config.draftLabelName ?? "下書き"]
|
|
164
|
+
const draftLabelName = draftLabelNames[0] ?? "下書き"
|
|
163
165
|
const logger = config.logger ?? createLogger(LogLevel.INFO, "[DailyReportService]")
|
|
164
166
|
|
|
165
167
|
const DAILY_REPORT_IDS_CACHE_KEY = "daily-report:ids"
|
|
@@ -318,9 +320,23 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
318
320
|
// Internal specific mappings if any
|
|
319
321
|
}
|
|
320
322
|
|
|
321
|
-
const labelsRaw = row.labels ?? []
|
|
323
|
+
const labelsRaw = (row.labels ?? []).map((l) => ({
|
|
324
|
+
id: l.id,
|
|
325
|
+
name: l.name,
|
|
326
|
+
color: l.color,
|
|
327
|
+
}))
|
|
322
328
|
const commentItemsRaw = row.comments ?? []
|
|
323
329
|
|
|
330
|
+
// 作成区分がアプリ設定の下書き候補に合致する場合、ラベル一覧に下書きラベルが含まれるよう補完
|
|
331
|
+
const isCreationCategoryDraft = creationCategory ? draftLabelNames.includes(creationCategory) : false
|
|
332
|
+
if (isCreationCategoryDraft && cachedDraftLabelId && !labelsRaw.some((l) => l.id === cachedDraftLabelId)) {
|
|
333
|
+
labelsRaw.push({
|
|
334
|
+
id: cachedDraftLabelId,
|
|
335
|
+
name: draftLabelName,
|
|
336
|
+
color: null,
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
|
|
324
340
|
return {
|
|
325
341
|
reportHubId: hub.id,
|
|
326
342
|
date: formatDateValue(hub.businessDate, "YYYY-MM-DD"),
|
|
@@ -342,11 +358,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
342
358
|
comments,
|
|
343
359
|
isRead: row.isRead ?? false,
|
|
344
360
|
isStarred: row.isStarred ?? false,
|
|
345
|
-
labels: labelsRaw
|
|
346
|
-
id: l.id,
|
|
347
|
-
name: l.name,
|
|
348
|
-
color: l.color,
|
|
349
|
-
})),
|
|
361
|
+
labels: labelsRaw,
|
|
350
362
|
commentItems: commentItemsRaw.map((c) => ({
|
|
351
363
|
...c,
|
|
352
364
|
userId: encodeUserId(c.userId),
|
|
@@ -361,6 +373,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
361
373
|
*/
|
|
362
374
|
const fetchDailyReportIdsByUserId = async (userId: number): Promise<DailyReportItem[]> => {
|
|
363
375
|
const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
|
|
376
|
+
const draftLabelId = await getDraftLabelId()
|
|
364
377
|
|
|
365
378
|
const result = await db
|
|
366
379
|
.select<{ reportHubId: number; businessDate: Date | string | null; sourceType: string }>({
|
|
@@ -369,8 +382,8 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
369
382
|
sourceType: DailyReportHub.sourceType,
|
|
370
383
|
})
|
|
371
384
|
.from(DailyReportHub)
|
|
372
|
-
.leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), eq(DraftLabelRelation.labelId,
|
|
373
|
-
.where(and(isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId))))
|
|
385
|
+
.leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), eq(DraftLabelRelation.labelId, draftLabelId ?? -1)))
|
|
386
|
+
.where(and(isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DailyReportHub.userId), isNull(DraftLabelRelation.hubId))))
|
|
374
387
|
.orderBy(desc(DailyReportHub.businessDate), desc(DailyReportHub.id))
|
|
375
388
|
|
|
376
389
|
return result.map((item) => ({
|
|
@@ -484,7 +497,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
484
497
|
let cachedDraftLabelId: number | null = null
|
|
485
498
|
const getDraftLabelId = async (): Promise<number | null> => {
|
|
486
499
|
if (cachedDraftLabelId !== null) return cachedDraftLabelId
|
|
487
|
-
const [label] = await db.select<{ id: number }>({ id: DailyReportLabel.id }).top(1).from(DailyReportLabel).where(
|
|
500
|
+
const [label] = await db.select<{ id: number }>({ id: DailyReportLabel.id }).top(1).from(DailyReportLabel).where(inArray(DailyReportLabel.name, draftLabelNames))
|
|
488
501
|
|
|
489
502
|
if (label) {
|
|
490
503
|
cachedDraftLabelId = label.id
|
|
@@ -867,6 +880,11 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
867
880
|
* 日報にコメントを追加する。
|
|
868
881
|
*/
|
|
869
882
|
const addComment = async (userId: number, reportHubId: number, content: string, businessDate: string | null, clientTempId: string): Promise<DailyReportCommentItem> => {
|
|
883
|
+
const [hub] = await db.select<{ sourceType: string }>({ sourceType: DailyReportHub.sourceType }).top(1).from(DailyReportHub).where(eq(DailyReportHub.id, reportHubId))
|
|
884
|
+
if (hub && hub.sourceType.toLowerCase() !== "internal") {
|
|
885
|
+
throw new Error("Comments are restricted for external daily report sources")
|
|
886
|
+
}
|
|
887
|
+
|
|
870
888
|
const [inserted] = await db
|
|
871
889
|
.insert<DailyReportCommentRow>(DailyReportCommentModel)
|
|
872
890
|
.output()
|
package/src/server.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { DailyReportAuthenticate } from "./server/ports"
|
|
|
9
9
|
import { createDailyReportService, type DailyReportServiceConfig } from "./server/service"
|
|
10
10
|
import { DailyReportSseReader } from "./server/sse-reader"
|
|
11
11
|
|
|
12
|
+
export * from "./server/authz"
|
|
12
13
|
export * from "./server/cache"
|
|
13
14
|
export { generateETag } from "./server/etag"
|
|
14
15
|
export * from "./server/external-source"
|