@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiquants/daily-report",
3
- "version": "0.6.0",
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.1"
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.1"
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
- <div className="mt-auto" ref={formContainerRef}>
671
- <DailyReportCommentForm reportHubId={report.reportHubId} businessDate={report.date ?? ""} onAddComment={handleAddCommentWrapper} />
672
- </div>
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: <DailyReportList dailyReportItems={dailyReportItems} autoMarkRead={autoMarkRead} selectedReportHubId={selectedItemId} onSelectItem={setSelectedItemId} userId={userId} />,
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: <DailyReportDetailList dailyReportItems={dailyReportItems} userId={userId} selectedItemId={selectedItemId} onSelectItem={setSelectedItemId} />,
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 (
@@ -42,6 +42,8 @@ export type DailyReportFieldLabels = {
42
42
  export type SourceTypeConfig = {
43
43
  label: string
44
44
  badgeClassName?: string
45
+ /** コメント投稿を許可するかどうか (既定 true)。false の場合、該当ソースの日報でコメント投稿フォームを非表示にする。 */
46
+ allowComment?: boolean
45
47
  }
46
48
 
47
49
  /** クライアント層の設定一式。 */
@@ -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
- fetch(`${apiBasePath}/ids`, {
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
+ }
@@ -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
  }
@@ -130,10 +130,11 @@ export type DailyReportServiceConfig = {
130
130
  /** クロスワーカー epoch ストア (facade が生成して注入)。 */
131
131
  epochs: EpochStore
132
132
  /**
133
- * 下書きラベル名 (必須)。ドラフトのクロスユーザー可視性フィルタに使うため、
134
- * サイレントな既定値を持たせず消費アプリが自 DB のラベル名を必ず注入する。
133
+ * 下書きラベル名 (単一または配列で指定可能)。
134
+ * 消費アプリ側で自 DB のラベル名や作成区分の候補名 ("下書き", "DRAFT" など) を注入できる。
135
135
  */
136
- draftLabelName: string
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 draftLabelName = config.draftLabelName
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.map((l) => ({
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, db.select({ id: DailyReportLabel.id }).top(1).from(DailyReportLabel).where(eq(DailyReportLabel.name, draftLabelName)))))
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(eq(DailyReportLabel.name, draftLabelName))
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"