@aiquants/daily-report 0.1.1
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/LICENSE +21 -0
- package/README.md +150 -0
- package/dist/client.d.mts +449 -0
- package/dist/client.d.ts +449 -0
- package/dist/client.js +7 -0
- package/dist/client.js.map +1 -0
- package/dist/client.mjs +7 -0
- package/dist/client.mjs.map +1 -0
- package/dist/index.d.mts +42 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/index.mjs.map +1 -0
- package/dist/logger-D3krZrNK.d.mts +29 -0
- package/dist/logger-D3krZrNK.d.ts +29 -0
- package/dist/server.d.mts +1515 -0
- package/dist/server.d.ts +1515 -0
- package/dist/server.js +10 -0
- package/dist/server.js.map +1 -0
- package/dist/server.mjs +10 -0
- package/dist/server.mjs.map +1 -0
- package/dist/sse-schema-CK7cUnEo.d.ts +1986 -0
- package/dist/sse-schema-yl5AaSsj.d.mts +1986 -0
- package/dist/types-CVhwLhSN.d.mts +76 -0
- package/dist/types-CVhwLhSN.d.ts +76 -0
- package/package.json +108 -0
- package/src/client/components/business-day-thumb-overlay.tsx +19 -0
- package/src/client/components/daily-report-comment-item.tsx +81 -0
- package/src/client/components/daily-report-comment-section.tsx +166 -0
- package/src/client/components/daily-report-detail-list.tsx +676 -0
- package/src/client/components/daily-report-edit-form.tsx +81 -0
- package/src/client/components/daily-report-list.tsx +1024 -0
- package/src/client/components/daily-report-page.tsx +147 -0
- package/src/client/components/daily-report-resolved-content.tsx +139 -0
- package/src/client/components/unread-indicator.tsx +13 -0
- package/src/client/config-context.tsx +129 -0
- package/src/client/contexts/daily-report-action-context.tsx +910 -0
- package/src/client/hooks/use-daily-report-comments.ts +73 -0
- package/src/client/hooks/use-daily-report-sse-connection.ts +86 -0
- package/src/client/hooks/use-daily-report.spec.ts +155 -0
- package/src/client/hooks/use-daily-report.ts +426 -0
- package/src/client/hooks/use-dynamic-viewport-height.ts +127 -0
- package/src/client/route-helpers.ts +76 -0
- package/src/client/ui/button.tsx +42 -0
- package/src/client/ui/cn.ts +14 -0
- package/src/client/ui/input.tsx +21 -0
- package/src/client/ui/label.tsx +16 -0
- package/src/client/ui/switch.tsx +19 -0
- package/src/client/ui/tabs.tsx +40 -0
- package/src/client/ui/textarea.tsx +19 -0
- package/src/client/utils/constants.ts +29 -0
- package/src/client.ts +21 -0
- package/src/index.ts +10 -0
- package/src/server/cache.ts +165 -0
- package/src/server/etag.ts +18 -0
- package/src/server/external-source.ts +60 -0
- package/src/server/handlers.ts +543 -0
- package/src/server/ports.ts +68 -0
- package/src/server/response.ts +37 -0
- package/src/server/schema.ts +266 -0
- package/src/server/service.spec.ts +97 -0
- package/src/server/service.ts +1308 -0
- package/src/server/sse-reader.spec.ts +55 -0
- package/src/server/sse-reader.ts +223 -0
- package/src/server.ts +83 -0
- package/src/shared/business-date.spec.ts +61 -0
- package/src/shared/business-date.ts +84 -0
- package/src/shared/comment-adapter.ts +78 -0
- package/src/shared/logger.ts +47 -0
- package/src/shared/sse-schema.ts +147 -0
- package/src/shared/text-utils.ts +57 -0
- package/src/shared/types.ts +76 -0
|
@@ -0,0 +1,1308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data-access service for daily reports (drizzle mssql), fully DI-configured.
|
|
3
|
+
* 日報データアクセスサービス (drizzle mssql)。依存はすべて DI で注入する。
|
|
4
|
+
*
|
|
5
|
+
* キャッシュキー・無効化順序・SSE publish 順序を含むデータアクセスロジックの中核。
|
|
6
|
+
*/
|
|
7
|
+
import { aliasedTable, and, asc, desc, eq, getColumns, inArray, isNull, or, sql } from "drizzle-orm"
|
|
8
|
+
import { normalizeBusinessDateKey } from "../shared/business-date"
|
|
9
|
+
import { createLogger, type DailyReportLogger, LogLevel } from "../shared/logger"
|
|
10
|
+
import { commentAddMessageSchema, commentDeleteMessageSchema, reportCreateMessageSchema, reportDeleteMessageSchema, reportPublishMessageSchema, reportUpdateMessageSchema, statusUpdateMessageSchema } from "../shared/sse-schema"
|
|
11
|
+
import type { DailyReportComment, DailyReportCommentItem, DailyReportDetail, DailyReportInterviewer, DailyReportItem, DailyReportLabelDef } from "../shared/types"
|
|
12
|
+
import type { EpochStore, SqlResultCache, SqlResultCacheQueryOptions } from "./cache"
|
|
13
|
+
import type { DailyReportExternalSource } from "./external-source"
|
|
14
|
+
import type { DailyReportEncodeUserId, DailyReportRedisProvider, DailyReportResolveUserId } from "./ports"
|
|
15
|
+
import type { DailyReportTables, DailyReportUserTable } from "./schema"
|
|
16
|
+
|
|
17
|
+
export type { SqlResultCacheQueryOptions }
|
|
18
|
+
|
|
19
|
+
// ---- 行型 (drizzle $inferSelect の構造的置き換え) ----
|
|
20
|
+
|
|
21
|
+
/** DailyReportHub の行型。 */
|
|
22
|
+
export type DailyReportHubRow = {
|
|
23
|
+
id: number
|
|
24
|
+
sourceType: string
|
|
25
|
+
sourceId: string
|
|
26
|
+
sourceIdNum: number | null
|
|
27
|
+
businessDate: Date | string | null
|
|
28
|
+
userId: number | null
|
|
29
|
+
title: string | null
|
|
30
|
+
summary: string | null
|
|
31
|
+
createdAt: Date | string
|
|
32
|
+
createdBy: string
|
|
33
|
+
updatedAt: Date | string
|
|
34
|
+
updatedBy: string
|
|
35
|
+
deletedAt: Date | string | null
|
|
36
|
+
deletedBy: string | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** DailyReportInternal の行型。 */
|
|
40
|
+
export type DailyReportInternalRow = {
|
|
41
|
+
hubId: number
|
|
42
|
+
body: string | null
|
|
43
|
+
metadata: string | null
|
|
44
|
+
createdAt: Date | string
|
|
45
|
+
createdBy: string
|
|
46
|
+
updatedAt: Date | string
|
|
47
|
+
updatedBy: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** DailyReportUserStatus の行型。 */
|
|
51
|
+
export type DailyReportUserStatusRow = {
|
|
52
|
+
hubId: number
|
|
53
|
+
userId: number
|
|
54
|
+
isRead: boolean
|
|
55
|
+
isStarred: boolean
|
|
56
|
+
createdAt: Date | string
|
|
57
|
+
createdBy: string
|
|
58
|
+
updatedAt: Date | string
|
|
59
|
+
updatedBy: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** DailyReportComment の行型。 */
|
|
63
|
+
export type DailyReportCommentRow = {
|
|
64
|
+
id: number
|
|
65
|
+
hubId: number
|
|
66
|
+
userId: number
|
|
67
|
+
body: string
|
|
68
|
+
createdAt: Date | string
|
|
69
|
+
createdBy: string
|
|
70
|
+
updatedAt: Date | string
|
|
71
|
+
updatedBy: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---- 必要最小の drizzle ビルダ形 (公開契約はサービス関数のシグネチャで厳格化) ----
|
|
75
|
+
|
|
76
|
+
type Rows<T> = PromiseLike<T[]>
|
|
77
|
+
interface SelectChain<T> extends Rows<T> {
|
|
78
|
+
from(t: unknown): SelectChain<T>
|
|
79
|
+
innerJoin(t: unknown, on: unknown): SelectChain<T>
|
|
80
|
+
leftJoin(t: unknown, on: unknown): SelectChain<T>
|
|
81
|
+
where(cond: unknown): SelectChain<T>
|
|
82
|
+
orderBy(...cols: unknown[]): SelectChain<T>
|
|
83
|
+
top(n: number): SelectChain<T>
|
|
84
|
+
}
|
|
85
|
+
interface InsertChain<T> {
|
|
86
|
+
output(): { values(v: unknown): PromiseLike<T[]> }
|
|
87
|
+
values(v: unknown): PromiseLike<unknown>
|
|
88
|
+
}
|
|
89
|
+
interface UpdateChain<T> {
|
|
90
|
+
set(v: unknown): {
|
|
91
|
+
where(cond: unknown): PromiseLike<unknown>
|
|
92
|
+
output(): { where(cond: unknown): PromiseLike<T[]> }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
interface DeleteChain {
|
|
96
|
+
where(cond: unknown): PromiseLike<unknown>
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** サービスが要求する drizzle mssql データベースの最小面 (トランザクション込み)。 */
|
|
100
|
+
export interface DailyReportDb {
|
|
101
|
+
select<T = Record<string, unknown>>(fields?: unknown): SelectChain<T>
|
|
102
|
+
insert<T = Record<string, unknown>>(t: unknown): InsertChain<T>
|
|
103
|
+
update<T = Record<string, unknown>>(t: unknown): UpdateChain<T>
|
|
104
|
+
delete(t: unknown): DeleteChain
|
|
105
|
+
transaction<T>(fn: (tx: DailyReportDb) => Promise<T>, config?: unknown): Promise<T>
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** getColumns へ構造型テーブルを渡すための境界キャスト。 */
|
|
109
|
+
const cols = (t: unknown) => getColumns(t as Parameters<typeof getColumns>[0])
|
|
110
|
+
|
|
111
|
+
// ---- サービス設定 ----
|
|
112
|
+
|
|
113
|
+
export type DailyReportServiceConfig = {
|
|
114
|
+
/** drizzle mssql データベースハンドル。 */
|
|
115
|
+
db: DailyReportDb
|
|
116
|
+
/** 日報 6 テーブル (アプリ既存モデルまたは defineDailyReportSchema 生成物)。 */
|
|
117
|
+
tables: DailyReportTables
|
|
118
|
+
/** 表示名解決に使う外部ユーザーテーブル ({ id, displayName })。 */
|
|
119
|
+
userTable: DailyReportUserTable
|
|
120
|
+
/** 外部ユーザー ID → 内部数値 ID の解決ポート。 */
|
|
121
|
+
resolveUserId: DailyReportResolveUserId
|
|
122
|
+
/** 内部数値 ID の難読化ポート。 */
|
|
123
|
+
encodeUserId: DailyReportEncodeUserId
|
|
124
|
+
/** SSE publish / epoch 用 redis (省略時は SSE publish スキップ・epoch 無効)。 */
|
|
125
|
+
redis?: DailyReportRedisProvider
|
|
126
|
+
/** レガシー等の外部ソースアダプタ群。 */
|
|
127
|
+
externalSources?: DailyReportExternalSource[]
|
|
128
|
+
/** SQL 結果キャッシュ (facade が生成して注入)。 */
|
|
129
|
+
cache: SqlResultCache
|
|
130
|
+
/** クロスワーカー epoch ストア (facade が生成して注入)。 */
|
|
131
|
+
epochs: EpochStore
|
|
132
|
+
/**
|
|
133
|
+
* 下書きラベル名 (必須)。ドラフトのクロスユーザー可視性フィルタに使うため、
|
|
134
|
+
* サイレントな既定値を持たせず消費アプリが自 DB のラベル名を必ず注入する。
|
|
135
|
+
*/
|
|
136
|
+
draftLabelName: string
|
|
137
|
+
/** SSE Redis Stream キー (既定 "daily-report:sse-stream")。 */
|
|
138
|
+
streamKey?: string
|
|
139
|
+
/** SSE Stream の MAXLEN (既定 10000)。 */
|
|
140
|
+
streamMaxLen?: number
|
|
141
|
+
/** IDs 一覧キャッシュ TTL (既定 180,000ms)。 */
|
|
142
|
+
idsTtlMs?: number
|
|
143
|
+
/** 営業日別キャッシュ TTL (既定 300,000ms)。 */
|
|
144
|
+
businessDateTtlMs?: number
|
|
145
|
+
/** 外部 ID → 内部 ID のプロセス内キャッシュを無効化 (テスト用)。 */
|
|
146
|
+
disableUserIdCache?: boolean
|
|
147
|
+
/** ロガー (既定は console ベース)。 */
|
|
148
|
+
logger?: DailyReportLogger
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** createDailyReportService の返却型。 */
|
|
152
|
+
export type DailyReportService = ReturnType<typeof createDailyReportService>
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Creates the daily-report data-access service bound to the injected dependencies.
|
|
156
|
+
* 注入された依存に束縛された日報データアクセスサービスを生成する処理。
|
|
157
|
+
*/
|
|
158
|
+
export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
159
|
+
const { db, tables, userTable: users, resolveUserId, encodeUserId, redis, cache: sqlResultCache, epochs } = config
|
|
160
|
+
const { hub: DailyReportHub, internal: DailyReportInternal, comment: DailyReportCommentModel, label: DailyReportLabel, hubLabel: DailyReportHub_Label, userStatus: DailyReportUserStatus } = tables
|
|
161
|
+
const externalSources = config.externalSources ?? []
|
|
162
|
+
const draftLabelName = config.draftLabelName
|
|
163
|
+
const logger = config.logger ?? createLogger(LogLevel.INFO, "[DailyReportService]")
|
|
164
|
+
|
|
165
|
+
const DAILY_REPORT_IDS_CACHE_KEY = "daily-report:ids"
|
|
166
|
+
const DAILY_REPORT_IDS_TTL_MS = config.idsTtlMs ?? 180_000
|
|
167
|
+
const DAILY_REPORT_IDS_EPOCH_KEY = "daily-report:ids:epoch"
|
|
168
|
+
|
|
169
|
+
const DAILY_REPORT_BUSINESS_DATE_CACHE_PREFIX = "daily-report:business-date:"
|
|
170
|
+
const DAILY_REPORT_BUSINESS_DATE_TTL_MS = config.businessDateTtlMs ?? 300_000
|
|
171
|
+
|
|
172
|
+
/** 営業日キャッシュのクロスワーカー epoch プレフィックス */
|
|
173
|
+
const DAILY_REPORT_DATE_EPOCH_PREFIX = "daily-report:date-epoch:"
|
|
174
|
+
/** レポート詳細キャッシュのクロスワーカー epoch プレフィックス */
|
|
175
|
+
const DAILY_REPORT_DETAIL_EPOCH_PREFIX = "daily-report:detail-epoch:"
|
|
176
|
+
|
|
177
|
+
const DAILY_REPORT_SSE_STREAM_KEY = config.streamKey ?? "daily-report:sse-stream"
|
|
178
|
+
const DAILY_REPORT_SSE_STREAM_MAXLEN = config.streamMaxLen ?? 10000
|
|
179
|
+
|
|
180
|
+
const incrementRedisEpoch = (key: string) => epochs.incrementEpoch(key)
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Publishes a message to the SSE Redis Stream.
|
|
184
|
+
* SSE 用の Redis Stream にメッセージを追加する。
|
|
185
|
+
*/
|
|
186
|
+
const publishToSseStream = async (message: Record<string, unknown>, callerName: string): Promise<void> => {
|
|
187
|
+
const client = await redis?.getClient()
|
|
188
|
+
if (!client) {
|
|
189
|
+
logger.warn(`[SSE] Redis client unavailable (${callerName})`)
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
const publishStartMs = Date.now()
|
|
193
|
+
try {
|
|
194
|
+
await client.xAdd(DAILY_REPORT_SSE_STREAM_KEY, "*", { data: JSON.stringify(message) }, { TRIM: { strategy: "MAXLEN", strategyModifier: "~", threshold: DAILY_REPORT_SSE_STREAM_MAXLEN } })
|
|
195
|
+
const publishDurationMs = Date.now() - publishStartMs
|
|
196
|
+
if (publishDurationMs > 1000) {
|
|
197
|
+
logger.warn(`[SSE] Slow publish (${callerName}): ${publishDurationMs}ms`)
|
|
198
|
+
}
|
|
199
|
+
} catch (e) {
|
|
200
|
+
logger.error(`[SSE] Redis publish failed (${callerName}):`, e)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
type DatePattern = "YYYY-MM-DD HH:mm:ss" | "YYYY-MM-DD"
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Formats date-like values to a given pattern (ja-JP locale semantics preserved).
|
|
208
|
+
* 日付相当の値を指定パターンに整形する処理。
|
|
209
|
+
*/
|
|
210
|
+
const formatDateValue = (value: Date | string | null | undefined, pattern: DatePattern): string | null => {
|
|
211
|
+
if (!value) {
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const dateValue = value instanceof Date ? value : new Date(value)
|
|
216
|
+
if (Number.isNaN(dateValue.getTime())) {
|
|
217
|
+
return typeof value === "string" ? value : null
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ja-JP ロケールの YYYY/MM/DD (HH:mm:ss) を生成しハイフン区切りへ正規化する
|
|
221
|
+
const opts: Intl.DateTimeFormatOptions = pattern === "YYYY-MM-DD HH:mm:ss" ? { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" } : { year: "numeric", month: "2-digit", day: "2-digit" }
|
|
222
|
+
const formatted = pattern === "YYYY-MM-DD HH:mm:ss" ? dateValue.toLocaleString("ja-JP", opts) : dateValue.toLocaleDateString("ja-JP", opts)
|
|
223
|
+
return formatted.replace(/\//g, "-")
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Masks a raw audit-actor value (created_by / updated_by) before client exposure.
|
|
228
|
+
* 監査列 (created_by / updated_by) をクライアント公開前にマスクする処理。
|
|
229
|
+
*
|
|
230
|
+
* Internal 由来の値は String(userId) の生内部 ID のため、数値なら encodeUserId で難読化する。
|
|
231
|
+
* 非数値 (外部ソース由来の氏名やシステム文字列) はそのまま返す。null / 空文字は null。
|
|
232
|
+
* userId フィールドと同じ難読化を監査列にも適用し、内部 ID の横流し漏洩を防ぐ。
|
|
233
|
+
*/
|
|
234
|
+
const maskAuditActor = (value: string | null | undefined): string | null => {
|
|
235
|
+
if (value == null || value === "") return null
|
|
236
|
+
return /^\d+$/.test(value) ? encodeUserId(Number(value)) : value
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
type RawJsonComment = {
|
|
240
|
+
id: number
|
|
241
|
+
content: string
|
|
242
|
+
createdAt: string
|
|
243
|
+
userId: number
|
|
244
|
+
userName: string
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
type RawJsonLabel = {
|
|
248
|
+
id: number
|
|
249
|
+
name: string
|
|
250
|
+
color: string | null
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 詳細クエリ 1 行 (外部ソース列は `ext_<sourceType>` キーで同居)。 */
|
|
254
|
+
type HubQueryRow = {
|
|
255
|
+
hub: DailyReportHubRow
|
|
256
|
+
internal: DailyReportInternalRow | null
|
|
257
|
+
isRead?: boolean | null
|
|
258
|
+
isStarred?: boolean | null
|
|
259
|
+
creatorName?: string | null
|
|
260
|
+
} & Record<string, unknown>
|
|
261
|
+
|
|
262
|
+
type HubRecord = HubQueryRow & {
|
|
263
|
+
labels?: RawJsonLabel[]
|
|
264
|
+
comments?: RawJsonComment[]
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** 外部ソースアダプタの select 追加フィールドを構築する。 */
|
|
268
|
+
const externalSelections = (): Record<string, unknown> => {
|
|
269
|
+
const out: Record<string, unknown> = {}
|
|
270
|
+
for (const adapter of externalSources) {
|
|
271
|
+
out[`ext_${adapter.sourceType}`] = cols(adapter.table)
|
|
272
|
+
}
|
|
273
|
+
return out
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** 外部ソースアダプタの LEFT JOIN を select チェーンへ適用する。 */
|
|
277
|
+
const applyExternalJoins = <T>(chain: SelectChain<T>): SelectChain<T> => {
|
|
278
|
+
let c = chain
|
|
279
|
+
for (const adapter of externalSources) {
|
|
280
|
+
c = c.leftJoin(adapter.table, and(eq(DailyReportHub.sourceType, adapter.sourceType), eq(DailyReportHub.sourceIdNum, adapter.idColumn)))
|
|
281
|
+
}
|
|
282
|
+
return c
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Converts a Hub record into a DailyReportDetail structure.
|
|
287
|
+
* Hub レコードを DailyReportDetail に変換する処理。
|
|
288
|
+
*/
|
|
289
|
+
const mapHubRecord = (row: HubRecord, currentUserId?: number): DailyReportDetail => {
|
|
290
|
+
const { hub, internal } = row
|
|
291
|
+
|
|
292
|
+
let content = hub.summary
|
|
293
|
+
let interviewers: DailyReportInterviewer[] = []
|
|
294
|
+
let category: string | null = null
|
|
295
|
+
let creationCategory: string | null = null
|
|
296
|
+
let visitTimeFrom: string | null = null
|
|
297
|
+
let visitTimeTo: string | null = null
|
|
298
|
+
let customerName: string | null = null
|
|
299
|
+
let employeeName: string | null = null
|
|
300
|
+
let comments: DailyReportComment[] = []
|
|
301
|
+
|
|
302
|
+
// 外部ソースアダプタ優先 → Internal の順で表示フィールドを解決する
|
|
303
|
+
const adapter = externalSources.find((a) => a.sourceType === hub.sourceType)
|
|
304
|
+
const externalRow = adapter ? (row[`ext_${adapter.sourceType}`] as Record<string, unknown> | null | undefined) : undefined
|
|
305
|
+
if (adapter && externalRow) {
|
|
306
|
+
const fields = adapter.mapRow(externalRow)
|
|
307
|
+
if (fields.content !== undefined) content = fields.content
|
|
308
|
+
if (fields.employeeName !== undefined) employeeName = fields.employeeName
|
|
309
|
+
if (fields.category !== undefined) category = fields.category
|
|
310
|
+
if (fields.creationCategory !== undefined) creationCategory = fields.creationCategory
|
|
311
|
+
if (fields.visitTimeFrom !== undefined) visitTimeFrom = fields.visitTimeFrom
|
|
312
|
+
if (fields.visitTimeTo !== undefined) visitTimeTo = fields.visitTimeTo
|
|
313
|
+
if (fields.customerName !== undefined) customerName = fields.customerName
|
|
314
|
+
if (fields.interviewers !== undefined) interviewers = fields.interviewers
|
|
315
|
+
if (fields.comments !== undefined) comments = fields.comments
|
|
316
|
+
} else if (hub.sourceType === "Internal" && internal) {
|
|
317
|
+
content = internal.body
|
|
318
|
+
// Internal specific mappings if any
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const labelsRaw = row.labels ?? []
|
|
322
|
+
const commentItemsRaw = row.comments ?? []
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
reportHubId: hub.id,
|
|
326
|
+
date: formatDateValue(hub.businessDate, "YYYY-MM-DD"),
|
|
327
|
+
author: row.creatorName ?? maskAuditActor(hub.createdBy) ?? "",
|
|
328
|
+
userId: hub.userId ? encodeUserId(hub.userId) : "",
|
|
329
|
+
createdAt: formatDateValue(hub.createdAt, "YYYY-MM-DD HH:mm:ss"),
|
|
330
|
+
updatedAt: formatDateValue(hub.updatedAt, "YYYY-MM-DD HH:mm:ss"),
|
|
331
|
+
updatedBy: maskAuditActor(hub.updatedBy),
|
|
332
|
+
employeeName: employeeName ?? row.creatorName ?? maskAuditActor(hub.createdBy),
|
|
333
|
+
category,
|
|
334
|
+
creationCategory,
|
|
335
|
+
visitTimeFrom,
|
|
336
|
+
visitTimeTo,
|
|
337
|
+
customerName,
|
|
338
|
+
interviewers,
|
|
339
|
+
subject: hub.title,
|
|
340
|
+
content,
|
|
341
|
+
comments,
|
|
342
|
+
isRead: row.isRead ?? false,
|
|
343
|
+
isStarred: row.isStarred ?? false,
|
|
344
|
+
labels: labelsRaw.map((l) => ({
|
|
345
|
+
id: l.id,
|
|
346
|
+
name: l.name,
|
|
347
|
+
color: l.color,
|
|
348
|
+
})),
|
|
349
|
+
commentItems: commentItemsRaw.map((c) => ({
|
|
350
|
+
...c,
|
|
351
|
+
userId: encodeUserId(c.userId),
|
|
352
|
+
isMine: currentUserId ? c.userId === currentUserId : false,
|
|
353
|
+
})),
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Fetches daily report IDs alongside normalized business dates.
|
|
359
|
+
* 日報 ID と正規化済み営業日を取得する内部処理。
|
|
360
|
+
*/
|
|
361
|
+
const fetchDailyReportIdsByUserId = async (userId: number): Promise<DailyReportItem[]> => {
|
|
362
|
+
const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
|
|
363
|
+
|
|
364
|
+
const result = await db
|
|
365
|
+
.select<{ reportHubId: number; businessDate: Date | string | null }>({
|
|
366
|
+
reportHubId: DailyReportHub.id,
|
|
367
|
+
businessDate: DailyReportHub.businessDate,
|
|
368
|
+
})
|
|
369
|
+
.from(DailyReportHub)
|
|
370
|
+
.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)))))
|
|
371
|
+
.where(and(isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId))))
|
|
372
|
+
.orderBy(desc(DailyReportHub.businessDate), desc(DailyReportHub.id))
|
|
373
|
+
|
|
374
|
+
return result.map((item) => ({
|
|
375
|
+
...item,
|
|
376
|
+
businessDate: formatDateValue(item.businessDate, "YYYY-MM-DD"),
|
|
377
|
+
}))
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Fetches daily report details for the provided business date.
|
|
382
|
+
* 指定した営業日の日報詳細を取得する内部処理。
|
|
383
|
+
*/
|
|
384
|
+
const fetchDailyReportsByBusinessDate = async (normalizedBusinessDate: string): Promise<DailyReportDetail[]> => {
|
|
385
|
+
const rows = await applyExternalJoins(
|
|
386
|
+
db
|
|
387
|
+
.select<HubQueryRow>({
|
|
388
|
+
hub: cols(DailyReportHub),
|
|
389
|
+
internal: cols(DailyReportInternal),
|
|
390
|
+
creatorName: users.displayName,
|
|
391
|
+
...externalSelections(),
|
|
392
|
+
})
|
|
393
|
+
.from(DailyReportHub),
|
|
394
|
+
)
|
|
395
|
+
.leftJoin(DailyReportInternal, eq(DailyReportHub.id, DailyReportInternal.hubId))
|
|
396
|
+
.leftJoin(users, eq(DailyReportHub.userId, users.id))
|
|
397
|
+
.where(and(eq(DailyReportHub.businessDate, sql`${normalizedBusinessDate}`), isNull(DailyReportHub.deletedAt)))
|
|
398
|
+
.orderBy(desc(DailyReportHub.id))
|
|
399
|
+
|
|
400
|
+
return rows.map((row) => mapHubRecord(row))
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Builds a deterministic cache key for business-date caches.
|
|
405
|
+
* 営業日キャッシュ用の一意キーを生成する処理。
|
|
406
|
+
*/
|
|
407
|
+
const buildBusinessDateCacheKey = (normalizedBusinessDate: string): string => {
|
|
408
|
+
return `${DAILY_REPORT_BUSINESS_DATE_CACHE_PREFIX}${normalizedBusinessDate}`
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Resolves the internal user ID from an external ID (with process-local caching).
|
|
413
|
+
* 外部 ID から内部ユーザー ID を解決する (プロセス内キャッシュ付き)。
|
|
414
|
+
*/
|
|
415
|
+
const userIdCache = new Map<string, number>()
|
|
416
|
+
|
|
417
|
+
const getUserIdByExternalId = async (externalId: string): Promise<number | null> => {
|
|
418
|
+
if (!config.disableUserIdCache && userIdCache.has(externalId)) {
|
|
419
|
+
return userIdCache.get(externalId) ?? null
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const userId = await resolveUserId(externalId)
|
|
423
|
+
if (userId !== null && !config.disableUserIdCache) {
|
|
424
|
+
userIdCache.set(externalId, userId)
|
|
425
|
+
}
|
|
426
|
+
return userId
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Retrieves daily report IDs and their normalized business dates.
|
|
431
|
+
* 日報 ID と正規化済み営業日を取得する処理。
|
|
432
|
+
*/
|
|
433
|
+
const getDailyReportIdsByExternalId = async (externalId: string, { forceRefresh = false, snapshot = false, ttlMsOverride }: SqlResultCacheQueryOptions = {}): Promise<DailyReportItem[]> => {
|
|
434
|
+
const userId = await getUserIdByExternalId(externalId)
|
|
435
|
+
if (!userId) {
|
|
436
|
+
return []
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const cacheKey = `${DAILY_REPORT_IDS_CACHE_KEY}:user:${userId}`
|
|
440
|
+
return sqlResultCache.getOrFetch<DailyReportItem>({
|
|
441
|
+
cacheKey,
|
|
442
|
+
fetcher: async () => {
|
|
443
|
+
const res = await fetchDailyReportIdsByUserId(userId)
|
|
444
|
+
return res
|
|
445
|
+
},
|
|
446
|
+
forceRefresh,
|
|
447
|
+
snapshot,
|
|
448
|
+
ttlMsOverride: ttlMsOverride ?? DAILY_REPORT_IDS_TTL_MS,
|
|
449
|
+
epochKey: DAILY_REPORT_IDS_EPOCH_KEY,
|
|
450
|
+
}) as Promise<DailyReportItem[]>
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Retrieves daily report details for a business date (⚠️ NOT per-user filtered).
|
|
455
|
+
* 指定した営業日に紐づく日報詳細を取得する処理 (⚠️ ユーザー別フィルタなし)。
|
|
456
|
+
*
|
|
457
|
+
* ⚠️ SECURITY: この関数は下書きラベルによる可視性フィルタ (他ユーザーの下書きを隠す) と
|
|
458
|
+
* ユーザー別の既読/スター状態を **適用しない**。取得結果は全ユーザーの下書きを含み得るため、
|
|
459
|
+
* HTTP レスポンス / SSR loader / SSE へ **直接返してはならない**。ユーザー向け配信には
|
|
460
|
+
* 必ず {@link getDailyReportsByBusinessDateByExternalId} を使うこと。
|
|
461
|
+
* (本関数は管理・バッチ・テスト用途に限定する。)
|
|
462
|
+
*/
|
|
463
|
+
const getDailyReportsByBusinessDate = (businessDate: string, { forceRefresh = false, snapshot = false, ttlMsOverride }: SqlResultCacheQueryOptions = {}): Promise<DailyReportDetail[]> => {
|
|
464
|
+
const normalizedBusinessDate = normalizeBusinessDateKey(businessDate)
|
|
465
|
+
if (!normalizedBusinessDate) {
|
|
466
|
+
return Promise.resolve([])
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return sqlResultCache.getOrFetch<DailyReportDetail>({
|
|
470
|
+
cacheKey: buildBusinessDateCacheKey(normalizedBusinessDate),
|
|
471
|
+
fetcher: () => fetchDailyReportsByBusinessDate(normalizedBusinessDate),
|
|
472
|
+
forceRefresh,
|
|
473
|
+
snapshot,
|
|
474
|
+
ttlMsOverride: ttlMsOverride ?? DAILY_REPORT_BUSINESS_DATE_TTL_MS,
|
|
475
|
+
}) as Promise<DailyReportDetail[]>
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Retrieves the ID of the draft label.
|
|
480
|
+
* 下書きラベルの ID を取得する(キャッシュ付き)。
|
|
481
|
+
*/
|
|
482
|
+
let cachedDraftLabelId: number | null = null
|
|
483
|
+
const getDraftLabelId = async (): Promise<number | null> => {
|
|
484
|
+
if (cachedDraftLabelId !== null) return cachedDraftLabelId
|
|
485
|
+
const [label] = await db.select<{ id: number }>({ id: DailyReportLabel.id }).top(1).from(DailyReportLabel).where(eq(DailyReportLabel.name, draftLabelName))
|
|
486
|
+
|
|
487
|
+
if (label) {
|
|
488
|
+
cachedDraftLabelId = label.id
|
|
489
|
+
return cachedDraftLabelId
|
|
490
|
+
}
|
|
491
|
+
return null
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Retrieves daily report details for a business date with user-specific status and relations.
|
|
496
|
+
* 指定した営業日の日報詳細を、ユーザー状態と関連データを含めて取得する。
|
|
497
|
+
*/
|
|
498
|
+
const getDailyReportsByBusinessDateByExternalId = async (businessDate: string, externalId: string, { forceRefresh = false, snapshot = false, ttlMsOverride }: SqlResultCacheQueryOptions = {}): Promise<DailyReportDetail[]> => {
|
|
499
|
+
const userId = await getUserIdByExternalId(externalId)
|
|
500
|
+
if (!userId) {
|
|
501
|
+
return []
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const normalizedBusinessDate = normalizeBusinessDateKey(businessDate)
|
|
505
|
+
if (!normalizedBusinessDate) {
|
|
506
|
+
return []
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const cacheKey = `daily-report:date:${normalizedBusinessDate}:user:${userId}`
|
|
510
|
+
|
|
511
|
+
return sqlResultCache.getOrFetch<DailyReportDetail>({
|
|
512
|
+
cacheKey,
|
|
513
|
+
fetcher: async () => {
|
|
514
|
+
const draftLabelId = await getDraftLabelId()
|
|
515
|
+
const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
|
|
516
|
+
|
|
517
|
+
const rows = await applyExternalJoins(
|
|
518
|
+
db
|
|
519
|
+
.select<HubQueryRow>({
|
|
520
|
+
hub: cols(DailyReportHub),
|
|
521
|
+
internal: cols(DailyReportInternal),
|
|
522
|
+
isRead: DailyReportUserStatus.isRead,
|
|
523
|
+
isStarred: DailyReportUserStatus.isStarred,
|
|
524
|
+
creatorName: users.displayName,
|
|
525
|
+
...externalSelections(),
|
|
526
|
+
})
|
|
527
|
+
.from(DailyReportHub),
|
|
528
|
+
)
|
|
529
|
+
.leftJoin(DailyReportInternal, eq(DailyReportHub.id, DailyReportInternal.hubId))
|
|
530
|
+
.leftJoin(DailyReportUserStatus, and(eq(DailyReportHub.id, DailyReportUserStatus.hubId), eq(DailyReportUserStatus.userId, userId)))
|
|
531
|
+
.leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), eq(DraftLabelRelation.labelId, draftLabelId ?? -1)))
|
|
532
|
+
.leftJoin(users, eq(DailyReportHub.userId, users.id))
|
|
533
|
+
.where(and(eq(DailyReportHub.businessDate, sql`${normalizedBusinessDate}`), isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId))))
|
|
534
|
+
.orderBy(desc(DailyReportHub.id))
|
|
535
|
+
|
|
536
|
+
if (rows.length === 0) {
|
|
537
|
+
return []
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const hubIds = rows.map((r) => r.hub.id)
|
|
541
|
+
|
|
542
|
+
// Drizzle ORM batch queries to fetch labels and comments
|
|
543
|
+
const allLabels = await db
|
|
544
|
+
.select<{ hubId: number; id: number; name: string; color: string | null }>({
|
|
545
|
+
hubId: DailyReportHub_Label.hubId,
|
|
546
|
+
id: DailyReportLabel.id,
|
|
547
|
+
name: DailyReportLabel.name,
|
|
548
|
+
color: DailyReportLabel.color,
|
|
549
|
+
})
|
|
550
|
+
.from(DailyReportHub_Label)
|
|
551
|
+
.innerJoin(DailyReportLabel, eq(DailyReportHub_Label.labelId, DailyReportLabel.id))
|
|
552
|
+
.where(inArray(DailyReportHub_Label.hubId, hubIds))
|
|
553
|
+
|
|
554
|
+
const allComments = await db
|
|
555
|
+
.select<{ hubId: number; id: number; body: string; createdAt: Date | string; userId: number; userName: string | null }>({
|
|
556
|
+
hubId: DailyReportCommentModel.hubId,
|
|
557
|
+
id: DailyReportCommentModel.id,
|
|
558
|
+
body: DailyReportCommentModel.body,
|
|
559
|
+
createdAt: DailyReportCommentModel.createdAt,
|
|
560
|
+
userId: DailyReportCommentModel.userId,
|
|
561
|
+
userName: users.displayName,
|
|
562
|
+
})
|
|
563
|
+
.from(DailyReportCommentModel)
|
|
564
|
+
.leftJoin(users, eq(DailyReportCommentModel.userId, users.id))
|
|
565
|
+
.where(inArray(DailyReportCommentModel.hubId, hubIds))
|
|
566
|
+
.orderBy(asc(DailyReportCommentModel.createdAt))
|
|
567
|
+
|
|
568
|
+
const labelsMap = new Map<number, RawJsonLabel[]>()
|
|
569
|
+
const commentsMap = new Map<number, RawJsonComment[]>()
|
|
570
|
+
|
|
571
|
+
for (const label of allLabels) {
|
|
572
|
+
let list = labelsMap.get(label.hubId)
|
|
573
|
+
if (!list) {
|
|
574
|
+
list = []
|
|
575
|
+
labelsMap.set(label.hubId, list)
|
|
576
|
+
}
|
|
577
|
+
list.push({
|
|
578
|
+
id: label.id,
|
|
579
|
+
name: label.name,
|
|
580
|
+
color: label.color,
|
|
581
|
+
})
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
for (const comment of allComments) {
|
|
585
|
+
let list = commentsMap.get(comment.hubId)
|
|
586
|
+
if (!list) {
|
|
587
|
+
list = []
|
|
588
|
+
commentsMap.set(comment.hubId, list)
|
|
589
|
+
}
|
|
590
|
+
list.push({
|
|
591
|
+
id: comment.id,
|
|
592
|
+
content: comment.body,
|
|
593
|
+
createdAt: formatDateValue(comment.createdAt, "YYYY-MM-DD HH:mm:ss") || "",
|
|
594
|
+
userId: comment.userId,
|
|
595
|
+
userName: comment.userName || "",
|
|
596
|
+
})
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return rows.map((row) => {
|
|
600
|
+
const hubId = row.hub.id
|
|
601
|
+
return mapHubRecord(
|
|
602
|
+
{
|
|
603
|
+
...row,
|
|
604
|
+
labels: labelsMap.get(hubId) || [],
|
|
605
|
+
comments: commentsMap.get(hubId) || [],
|
|
606
|
+
},
|
|
607
|
+
userId,
|
|
608
|
+
)
|
|
609
|
+
})
|
|
610
|
+
},
|
|
611
|
+
forceRefresh,
|
|
612
|
+
snapshot,
|
|
613
|
+
ttlMsOverride: ttlMsOverride ?? DAILY_REPORT_BUSINESS_DATE_TTL_MS,
|
|
614
|
+
epochKey: `${DAILY_REPORT_DATE_EPOCH_PREFIX}${normalizedBusinessDate}`,
|
|
615
|
+
}) as Promise<DailyReportDetail[]>
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Retrieves a single daily report detail by ID with user-specific status and relations.
|
|
620
|
+
* 指定した日報詳細を、ユーザー状態と関連データを含めて取得する。
|
|
621
|
+
*/
|
|
622
|
+
const getDailyReportDetailById = async (reportHubId: number, userId: number, { forceRefresh = false, snapshot = false, ttlMsOverride }: SqlResultCacheQueryOptions = {}): Promise<DailyReportDetail | null> => {
|
|
623
|
+
const cacheKey = `daily-report:detail:${reportHubId}:user:${userId}`
|
|
624
|
+
|
|
625
|
+
const results = (await sqlResultCache.getOrFetch<DailyReportDetail>({
|
|
626
|
+
cacheKey,
|
|
627
|
+
fetcher: async () => {
|
|
628
|
+
const rows = await applyExternalJoins(
|
|
629
|
+
db
|
|
630
|
+
.select<HubQueryRow>({
|
|
631
|
+
hub: cols(DailyReportHub),
|
|
632
|
+
internal: cols(DailyReportInternal),
|
|
633
|
+
isRead: DailyReportUserStatus.isRead,
|
|
634
|
+
isStarred: DailyReportUserStatus.isStarred,
|
|
635
|
+
creatorName: users.displayName,
|
|
636
|
+
...externalSelections(),
|
|
637
|
+
})
|
|
638
|
+
.from(DailyReportHub),
|
|
639
|
+
)
|
|
640
|
+
.leftJoin(DailyReportInternal, eq(DailyReportHub.id, DailyReportInternal.hubId))
|
|
641
|
+
.leftJoin(DailyReportUserStatus, and(eq(DailyReportHub.id, DailyReportUserStatus.hubId), eq(DailyReportUserStatus.userId, userId)))
|
|
642
|
+
.leftJoin(users, eq(DailyReportHub.userId, users.id))
|
|
643
|
+
.where(and(eq(DailyReportHub.id, reportHubId), isNull(DailyReportHub.deletedAt)))
|
|
644
|
+
|
|
645
|
+
if (rows.length === 0) {
|
|
646
|
+
return []
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const hubId = rows[0].hub.id
|
|
650
|
+
|
|
651
|
+
// Fetch labels and comments via Drizzle ORM
|
|
652
|
+
const labels = await db
|
|
653
|
+
.select<{ id: number; name: string; color: string | null }>({
|
|
654
|
+
id: DailyReportLabel.id,
|
|
655
|
+
name: DailyReportLabel.name,
|
|
656
|
+
color: DailyReportLabel.color,
|
|
657
|
+
})
|
|
658
|
+
.from(DailyReportHub_Label)
|
|
659
|
+
.innerJoin(DailyReportLabel, eq(DailyReportHub_Label.labelId, DailyReportLabel.id))
|
|
660
|
+
.where(eq(DailyReportHub_Label.hubId, hubId))
|
|
661
|
+
|
|
662
|
+
const comments = await db
|
|
663
|
+
.select<{ id: number; body: string; createdAt: Date | string; userId: number; userName: string | null }>({
|
|
664
|
+
id: DailyReportCommentModel.id,
|
|
665
|
+
body: DailyReportCommentModel.body,
|
|
666
|
+
createdAt: DailyReportCommentModel.createdAt,
|
|
667
|
+
userId: DailyReportCommentModel.userId,
|
|
668
|
+
userName: users.displayName,
|
|
669
|
+
})
|
|
670
|
+
.from(DailyReportCommentModel)
|
|
671
|
+
.leftJoin(users, eq(DailyReportCommentModel.userId, users.id))
|
|
672
|
+
.where(eq(DailyReportCommentModel.hubId, hubId))
|
|
673
|
+
.orderBy(asc(DailyReportCommentModel.createdAt))
|
|
674
|
+
|
|
675
|
+
const labelsMapped = labels.map((l) => ({
|
|
676
|
+
id: l.id,
|
|
677
|
+
name: l.name,
|
|
678
|
+
color: l.color,
|
|
679
|
+
}))
|
|
680
|
+
|
|
681
|
+
const commentsMapped = comments.map((c) => ({
|
|
682
|
+
id: c.id,
|
|
683
|
+
content: c.body,
|
|
684
|
+
createdAt: formatDateValue(c.createdAt, "YYYY-MM-DD HH:mm:ss") || "",
|
|
685
|
+
userId: c.userId,
|
|
686
|
+
userName: c.userName || "",
|
|
687
|
+
}))
|
|
688
|
+
|
|
689
|
+
return [
|
|
690
|
+
mapHubRecord(
|
|
691
|
+
{
|
|
692
|
+
...rows[0],
|
|
693
|
+
labels: labelsMapped,
|
|
694
|
+
comments: commentsMapped,
|
|
695
|
+
},
|
|
696
|
+
userId,
|
|
697
|
+
),
|
|
698
|
+
]
|
|
699
|
+
},
|
|
700
|
+
forceRefresh,
|
|
701
|
+
snapshot,
|
|
702
|
+
ttlMsOverride: ttlMsOverride ?? DAILY_REPORT_BUSINESS_DATE_TTL_MS,
|
|
703
|
+
epochKey: `${DAILY_REPORT_DETAIL_EPOCH_PREFIX}${reportHubId}`,
|
|
704
|
+
})) as DailyReportDetail[]
|
|
705
|
+
|
|
706
|
+
return results[0] ?? null
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Retrieves a single daily report detail by ID with user-specific status and relations.
|
|
711
|
+
* 指定した日報詳細を、ユーザー状態と関連データを含めて取得する。
|
|
712
|
+
*/
|
|
713
|
+
const getDailyReportDetailByIdByExternalId = async (reportHubId: number, externalId: string, options: SqlResultCacheQueryOptions = {}): Promise<DailyReportDetail | null> => {
|
|
714
|
+
const userId = await getUserIdByExternalId(externalId)
|
|
715
|
+
if (!userId) {
|
|
716
|
+
return null
|
|
717
|
+
}
|
|
718
|
+
return getDailyReportDetailById(reportHubId, userId, options)
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Sets the starred status of a daily report for a user.
|
|
723
|
+
* ユーザーの日報スター状態を設定する。
|
|
724
|
+
*/
|
|
725
|
+
const setStarStatus = async (userId: number, reportHubId: number, businessDate: string | null, isStarred: boolean, clientTempId: string): Promise<DailyReportUserStatusRow> => {
|
|
726
|
+
const existing = await db
|
|
727
|
+
.select<DailyReportUserStatusRow>()
|
|
728
|
+
.top(1)
|
|
729
|
+
.from(DailyReportUserStatus)
|
|
730
|
+
.where(and(eq(DailyReportUserStatus.hubId, reportHubId), eq(DailyReportUserStatus.userId, userId)))
|
|
731
|
+
|
|
732
|
+
let updatedStatus: DailyReportUserStatusRow
|
|
733
|
+
|
|
734
|
+
if (existing.length > 0) {
|
|
735
|
+
if (existing[0].isStarred !== isStarred) {
|
|
736
|
+
const rows = await db
|
|
737
|
+
.update<DailyReportUserStatusRow>(DailyReportUserStatus)
|
|
738
|
+
.set({
|
|
739
|
+
isStarred: isStarred,
|
|
740
|
+
updatedAt: new Date(),
|
|
741
|
+
updatedBy: String(userId),
|
|
742
|
+
})
|
|
743
|
+
.output()
|
|
744
|
+
.where(and(eq(DailyReportUserStatus.hubId, reportHubId), eq(DailyReportUserStatus.userId, userId)))
|
|
745
|
+
updatedStatus = rows[0]
|
|
746
|
+
} else {
|
|
747
|
+
updatedStatus = existing[0]
|
|
748
|
+
}
|
|
749
|
+
} else {
|
|
750
|
+
const rows = await db
|
|
751
|
+
.insert<DailyReportUserStatusRow>(DailyReportUserStatus)
|
|
752
|
+
.output()
|
|
753
|
+
.values({
|
|
754
|
+
hubId: reportHubId,
|
|
755
|
+
userId,
|
|
756
|
+
isStarred: isStarred,
|
|
757
|
+
isRead: false,
|
|
758
|
+
createdAt: new Date(),
|
|
759
|
+
createdBy: String(userId),
|
|
760
|
+
updatedAt: new Date(),
|
|
761
|
+
updatedBy: String(userId),
|
|
762
|
+
})
|
|
763
|
+
updatedStatus = rows[0]
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (businessDate) {
|
|
767
|
+
const normalizedDate = normalizeBusinessDateKey(businessDate)
|
|
768
|
+
if (normalizedDate) {
|
|
769
|
+
sqlResultCache.invalidate(`daily-report:date:${normalizedDate}:user:${userId}`)
|
|
770
|
+
await incrementRedisEpoch(`${DAILY_REPORT_DATE_EPOCH_PREFIX}${normalizedDate}`)
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
sqlResultCache.invalidate(`daily-report:detail:${reportHubId}`)
|
|
774
|
+
sqlResultCache.invalidate(`daily-report:detail:${reportHubId}:user:${userId}`)
|
|
775
|
+
await incrementRedisEpoch(`${DAILY_REPORT_DETAIL_EPOCH_PREFIX}${reportHubId}`)
|
|
776
|
+
|
|
777
|
+
await publishToSseStream(
|
|
778
|
+
statusUpdateMessageSchema.parse({
|
|
779
|
+
type: "status-update",
|
|
780
|
+
reportHubId: reportHubId,
|
|
781
|
+
recipientRawUserId: userId,
|
|
782
|
+
statusType: "star",
|
|
783
|
+
value: isStarred,
|
|
784
|
+
clientTempId,
|
|
785
|
+
}),
|
|
786
|
+
"setStarStatus",
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
return updatedStatus
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Sets the read status of a daily report for a user.
|
|
794
|
+
* ユーザーの日報既読状態を設定する。
|
|
795
|
+
*/
|
|
796
|
+
const setReadStatus = async (userId: number, reportHubId: number, businessDate: string | null, isRead: boolean, clientTempId: string): Promise<DailyReportUserStatusRow> => {
|
|
797
|
+
const existing = await db
|
|
798
|
+
.select<DailyReportUserStatusRow>()
|
|
799
|
+
.top(1)
|
|
800
|
+
.from(DailyReportUserStatus)
|
|
801
|
+
.where(and(eq(DailyReportUserStatus.hubId, reportHubId), eq(DailyReportUserStatus.userId, userId)))
|
|
802
|
+
|
|
803
|
+
let updatedStatus: DailyReportUserStatusRow
|
|
804
|
+
|
|
805
|
+
if (existing.length > 0) {
|
|
806
|
+
if (existing[0].isRead !== isRead) {
|
|
807
|
+
const rows = await db
|
|
808
|
+
.update<DailyReportUserStatusRow>(DailyReportUserStatus)
|
|
809
|
+
.set({
|
|
810
|
+
isRead: isRead,
|
|
811
|
+
updatedAt: new Date(),
|
|
812
|
+
updatedBy: String(userId),
|
|
813
|
+
})
|
|
814
|
+
.output()
|
|
815
|
+
.where(and(eq(DailyReportUserStatus.hubId, reportHubId), eq(DailyReportUserStatus.userId, userId)))
|
|
816
|
+
updatedStatus = rows[0]
|
|
817
|
+
} else {
|
|
818
|
+
updatedStatus = existing[0]
|
|
819
|
+
}
|
|
820
|
+
} else {
|
|
821
|
+
const rows = await db
|
|
822
|
+
.insert<DailyReportUserStatusRow>(DailyReportUserStatus)
|
|
823
|
+
.output()
|
|
824
|
+
.values({
|
|
825
|
+
hubId: reportHubId,
|
|
826
|
+
userId,
|
|
827
|
+
isRead: isRead,
|
|
828
|
+
isStarred: false,
|
|
829
|
+
createdAt: new Date(),
|
|
830
|
+
createdBy: String(userId),
|
|
831
|
+
updatedAt: new Date(),
|
|
832
|
+
updatedBy: String(userId),
|
|
833
|
+
})
|
|
834
|
+
updatedStatus = rows[0]
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (businessDate) {
|
|
838
|
+
const normalizedDate = normalizeBusinessDateKey(businessDate)
|
|
839
|
+
if (normalizedDate) {
|
|
840
|
+
sqlResultCache.invalidate(`daily-report:date:${normalizedDate}:user:${userId}`)
|
|
841
|
+
await incrementRedisEpoch(`${DAILY_REPORT_DATE_EPOCH_PREFIX}${normalizedDate}`)
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
sqlResultCache.invalidate(`daily-report:detail:${reportHubId}`)
|
|
845
|
+
sqlResultCache.invalidate(`daily-report:detail:${reportHubId}:user:${userId}`)
|
|
846
|
+
await incrementRedisEpoch(`${DAILY_REPORT_DETAIL_EPOCH_PREFIX}${reportHubId}`)
|
|
847
|
+
|
|
848
|
+
await publishToSseStream(
|
|
849
|
+
statusUpdateMessageSchema.parse({
|
|
850
|
+
type: "status-update",
|
|
851
|
+
reportHubId: reportHubId,
|
|
852
|
+
recipientRawUserId: userId,
|
|
853
|
+
statusType: "read",
|
|
854
|
+
value: isRead,
|
|
855
|
+
clientTempId,
|
|
856
|
+
}),
|
|
857
|
+
"setReadStatus",
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
return updatedStatus
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Adds a comment to a daily report.
|
|
865
|
+
* 日報にコメントを追加する。
|
|
866
|
+
*/
|
|
867
|
+
const addComment = async (userId: number, reportHubId: number, content: string, businessDate: string | null, clientTempId: string): Promise<DailyReportCommentItem> => {
|
|
868
|
+
const [inserted] = await db
|
|
869
|
+
.insert<DailyReportCommentRow>(DailyReportCommentModel)
|
|
870
|
+
.output()
|
|
871
|
+
.values({
|
|
872
|
+
hubId: reportHubId,
|
|
873
|
+
userId,
|
|
874
|
+
body: content,
|
|
875
|
+
createdAt: new Date(),
|
|
876
|
+
createdBy: String(userId),
|
|
877
|
+
updatedAt: new Date(),
|
|
878
|
+
updatedBy: String(userId),
|
|
879
|
+
})
|
|
880
|
+
|
|
881
|
+
const [user] = await db.select<{ displayName: string | null }>({ displayName: users.displayName }).top(1).from(users).where(eq(users.id, userId))
|
|
882
|
+
const userName = user?.displayName ?? "Unknown"
|
|
883
|
+
|
|
884
|
+
// コメントは全ユーザーに表示されるため、date キャッシュも全ユーザー分を無効化
|
|
885
|
+
if (businessDate) {
|
|
886
|
+
const normalizedDate = normalizeBusinessDateKey(businessDate)
|
|
887
|
+
if (normalizedDate) {
|
|
888
|
+
sqlResultCache.invalidatePrefix(`daily-report:date:${normalizedDate}:user:`)
|
|
889
|
+
await incrementRedisEpoch(`${DAILY_REPORT_DATE_EPOCH_PREFIX}${normalizedDate}`)
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
sqlResultCache.invalidate(`daily-report:detail:${reportHubId}`)
|
|
893
|
+
sqlResultCache.invalidatePrefix(`daily-report:detail:${reportHubId}:user:`)
|
|
894
|
+
await incrementRedisEpoch(`${DAILY_REPORT_DETAIL_EPOCH_PREFIX}${reportHubId}`)
|
|
895
|
+
|
|
896
|
+
const commentItem: DailyReportCommentItem = {
|
|
897
|
+
id: inserted.id,
|
|
898
|
+
userId: encodeUserId(inserted.userId),
|
|
899
|
+
userName: userName,
|
|
900
|
+
content: inserted.body,
|
|
901
|
+
createdAt: formatDateValue(inserted.createdAt, "YYYY-MM-DD HH:mm:ss") ?? "",
|
|
902
|
+
isMine: true,
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
await publishToSseStream(
|
|
906
|
+
commentAddMessageSchema.parse({
|
|
907
|
+
type: "comment-add",
|
|
908
|
+
reportHubId: reportHubId,
|
|
909
|
+
comment: commentItem,
|
|
910
|
+
clientTempId,
|
|
911
|
+
}),
|
|
912
|
+
"addComment",
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
return commentItem
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Finds a daily report comment by ID.
|
|
920
|
+
* 日報コメントをIDで検索する。
|
|
921
|
+
*/
|
|
922
|
+
const findDailyReportCommentById = async (tx: DailyReportDb, commentId: number) => {
|
|
923
|
+
return await tx.select<DailyReportCommentRow>().top(1).from(DailyReportCommentModel).where(eq(DailyReportCommentModel.id, commentId))
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Deletes a comment from a daily report.
|
|
928
|
+
* 日報のコメントを削除する。
|
|
929
|
+
*/
|
|
930
|
+
const deleteComment = async (userId: number, reportHubId: number, commentId: number, businessDate: string | null, clientTempId: string): Promise<void> => {
|
|
931
|
+
const comments = await findDailyReportCommentById(db, commentId)
|
|
932
|
+
if (comments.length === 0) {
|
|
933
|
+
throw new Error("Not Found")
|
|
934
|
+
}
|
|
935
|
+
if (comments[0].userId !== userId) {
|
|
936
|
+
throw new Error("Unauthorized")
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
await db.delete(DailyReportCommentModel).where(eq(DailyReportCommentModel.id, commentId))
|
|
940
|
+
|
|
941
|
+
// コメントは全ユーザーに表示されるため、date キャッシュも全ユーザー分を無効化
|
|
942
|
+
if (businessDate) {
|
|
943
|
+
const normalizedDate = normalizeBusinessDateKey(businessDate)
|
|
944
|
+
if (normalizedDate) {
|
|
945
|
+
sqlResultCache.invalidatePrefix(`daily-report:date:${normalizedDate}:user:`)
|
|
946
|
+
await incrementRedisEpoch(`${DAILY_REPORT_DATE_EPOCH_PREFIX}${normalizedDate}`)
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
sqlResultCache.invalidate(`daily-report:detail:${reportHubId}`)
|
|
950
|
+
sqlResultCache.invalidatePrefix(`daily-report:detail:${reportHubId}:user:`)
|
|
951
|
+
await incrementRedisEpoch(`${DAILY_REPORT_DETAIL_EPOCH_PREFIX}${reportHubId}`)
|
|
952
|
+
|
|
953
|
+
await publishToSseStream(
|
|
954
|
+
commentDeleteMessageSchema.parse({
|
|
955
|
+
type: "comment-delete",
|
|
956
|
+
reportHubId: reportHubId,
|
|
957
|
+
commentId,
|
|
958
|
+
clientTempId,
|
|
959
|
+
}),
|
|
960
|
+
"deleteComment",
|
|
961
|
+
)
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Creates a new draft daily report.
|
|
966
|
+
* 新しい日報(下書き)を作成する。
|
|
967
|
+
*/
|
|
968
|
+
const createDailyReport = async (userId: number, businessDate: string, clientTempId: string): Promise<DailyReportDetail> => {
|
|
969
|
+
logger.info("createDailyReport called", { userId, businessDate })
|
|
970
|
+
|
|
971
|
+
// ユーザー名取得
|
|
972
|
+
const [user] = await db.select<{ displayName: string | null }>({ displayName: users.displayName }).from(users).where(eq(users.id, userId))
|
|
973
|
+
const userName = user?.displayName ?? encodeUserId(userId)
|
|
974
|
+
|
|
975
|
+
try {
|
|
976
|
+
const result = await db.transaction(async (tx) => {
|
|
977
|
+
logger.info("Starting transaction")
|
|
978
|
+
// 1. Hub作成
|
|
979
|
+
const sourceId = `internal-temp-${Date.now()}-${Math.random()}` // 一時的なID
|
|
980
|
+
const [hub] = await tx
|
|
981
|
+
.insert<DailyReportHubRow>(DailyReportHub)
|
|
982
|
+
.output()
|
|
983
|
+
.values({
|
|
984
|
+
sourceType: "Internal",
|
|
985
|
+
sourceId: sourceId,
|
|
986
|
+
businessDate: new Date(businessDate),
|
|
987
|
+
userId: userId,
|
|
988
|
+
title: "(無題)",
|
|
989
|
+
createdAt: new Date(),
|
|
990
|
+
createdBy: String(userId),
|
|
991
|
+
updatedAt: new Date(),
|
|
992
|
+
updatedBy: String(userId),
|
|
993
|
+
})
|
|
994
|
+
|
|
995
|
+
// sourceId を id と同じ値に更新 (Internal の場合の正規化)
|
|
996
|
+
await tx
|
|
997
|
+
.update(DailyReportHub)
|
|
998
|
+
.set({ sourceId: String(hub.id) })
|
|
999
|
+
.where(eq(DailyReportHub.id, hub.id))
|
|
1000
|
+
|
|
1001
|
+
logger.info("Hub created", hub)
|
|
1002
|
+
|
|
1003
|
+
// 2. Internal作成
|
|
1004
|
+
await tx.insert(DailyReportInternal).values({
|
|
1005
|
+
hubId: hub.id,
|
|
1006
|
+
body: "",
|
|
1007
|
+
createdAt: new Date(),
|
|
1008
|
+
createdBy: String(userId),
|
|
1009
|
+
updatedAt: new Date(),
|
|
1010
|
+
updatedBy: String(userId),
|
|
1011
|
+
})
|
|
1012
|
+
logger.info("Internal created")
|
|
1013
|
+
|
|
1014
|
+
// 3. ラベル付与
|
|
1015
|
+
logger.info("Calling getDraftLabelId")
|
|
1016
|
+
const draftLabelId = await getDraftLabelId()
|
|
1017
|
+
logger.info("draftLabelId", draftLabelId)
|
|
1018
|
+
const labels: DailyReportLabelDef[] = []
|
|
1019
|
+
if (draftLabelId) {
|
|
1020
|
+
await tx.insert(DailyReportHub_Label).values({
|
|
1021
|
+
hubId: hub.id,
|
|
1022
|
+
labelId: draftLabelId,
|
|
1023
|
+
createdAt: new Date(),
|
|
1024
|
+
createdBy: String(userId),
|
|
1025
|
+
})
|
|
1026
|
+
labels.push({ id: draftLabelId, name: draftLabelName, color: null })
|
|
1027
|
+
}
|
|
1028
|
+
logger.info("Label assigned")
|
|
1029
|
+
|
|
1030
|
+
// 4. 詳細オブジェクト構築
|
|
1031
|
+
return {
|
|
1032
|
+
reportHubId: hub.id,
|
|
1033
|
+
date: businessDate,
|
|
1034
|
+
createdAt: formatDateValue(hub.createdAt, "YYYY-MM-DD HH:mm:ss"),
|
|
1035
|
+
author: userName,
|
|
1036
|
+
userId: encodeUserId(userId),
|
|
1037
|
+
employeeName: userName,
|
|
1038
|
+
// 監査列と同様、クライアント公開時は生内部 ID を難読化する (mapHubRecord と整合)
|
|
1039
|
+
updatedBy: encodeUserId(userId),
|
|
1040
|
+
updatedAt: formatDateValue(hub.updatedAt, "YYYY-MM-DD HH:mm:ss"),
|
|
1041
|
+
category: null,
|
|
1042
|
+
creationCategory: null,
|
|
1043
|
+
visitTimeFrom: null,
|
|
1044
|
+
visitTimeTo: null,
|
|
1045
|
+
customerName: null,
|
|
1046
|
+
interviewers: [],
|
|
1047
|
+
subject: hub.title,
|
|
1048
|
+
content: "",
|
|
1049
|
+
comments: [],
|
|
1050
|
+
isRead: true, // 自分で作ったので既読
|
|
1051
|
+
isStarred: false,
|
|
1052
|
+
labels: labels,
|
|
1053
|
+
commentItems: [],
|
|
1054
|
+
}
|
|
1055
|
+
})
|
|
1056
|
+
|
|
1057
|
+
// キャッシュ無効化
|
|
1058
|
+
const normalizedBusinessDate = normalizeBusinessDateKey(businessDate)
|
|
1059
|
+
if (normalizedBusinessDate) {
|
|
1060
|
+
sqlResultCache.invalidate(`daily-report:date:${normalizedBusinessDate}:user:${userId}`)
|
|
1061
|
+
}
|
|
1062
|
+
sqlResultCache.invalidatePrefix(DAILY_REPORT_BUSINESS_DATE_CACHE_PREFIX)
|
|
1063
|
+
sqlResultCache.invalidate(`${DAILY_REPORT_IDS_CACHE_KEY}:user:${userId}`)
|
|
1064
|
+
// ❗ 順序重要: invalidate → incrementRedisEpoch → publishToSseStream
|
|
1065
|
+
await incrementRedisEpoch(DAILY_REPORT_IDS_EPOCH_KEY)
|
|
1066
|
+
if (normalizedBusinessDate) {
|
|
1067
|
+
await incrementRedisEpoch(`${DAILY_REPORT_DATE_EPOCH_PREFIX}${normalizedBusinessDate}`)
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const fullDetail = await getDailyReportDetailById(result.reportHubId, userId, { forceRefresh: true })
|
|
1071
|
+
|
|
1072
|
+
if (fullDetail) {
|
|
1073
|
+
// 下書き判定
|
|
1074
|
+
const draftLabelId = await getDraftLabelId()
|
|
1075
|
+
const isDraft = draftLabelId ? fullDetail.labels.some((l) => l.id === draftLabelId) : false
|
|
1076
|
+
await publishToSseStream(
|
|
1077
|
+
reportCreateMessageSchema.parse({
|
|
1078
|
+
type: "report-create",
|
|
1079
|
+
reportHubId: fullDetail.reportHubId,
|
|
1080
|
+
report: fullDetail,
|
|
1081
|
+
clientTempId,
|
|
1082
|
+
recipientRawUserId: isDraft ? userId : undefined,
|
|
1083
|
+
}),
|
|
1084
|
+
"createDailyReport",
|
|
1085
|
+
)
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
return fullDetail ?? result
|
|
1089
|
+
} catch (e) {
|
|
1090
|
+
logger.error("Error in createDailyReport", e)
|
|
1091
|
+
throw e
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// --- Internal Repository Layer ---
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Finds a DailyReportHub by ID.
|
|
1099
|
+
* 日報HubをIDで検索する。
|
|
1100
|
+
*/
|
|
1101
|
+
const findDailyReportHubById = async (tx: DailyReportDb, reportHubId: number) => {
|
|
1102
|
+
return await tx.select<DailyReportHubRow>().top(1).from(DailyReportHub).where(eq(DailyReportHub.id, reportHubId))
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* Updates a DailyReportHub.
|
|
1107
|
+
* 日報Hubを更新する。
|
|
1108
|
+
*/
|
|
1109
|
+
const updateDailyReportHub = async (tx: DailyReportDb, reportHubId: number, data: Partial<DailyReportHubRow>) => {
|
|
1110
|
+
await tx.update(DailyReportHub).set(data).where(eq(DailyReportHub.id, reportHubId))
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Updates a DailyReportInternal.
|
|
1115
|
+
* 日報Internalを更新する。
|
|
1116
|
+
*/
|
|
1117
|
+
const updateDailyReportInternal = async (tx: DailyReportDb, hubId: number, data: Partial<DailyReportInternalRow>) => {
|
|
1118
|
+
await tx.update(DailyReportInternal).set(data).where(eq(DailyReportInternal.hubId, hubId))
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* Deletes a label from a DailyReportHub.
|
|
1123
|
+
* 日報Hubからラベルを削除する。
|
|
1124
|
+
*/
|
|
1125
|
+
const deleteDailyReportLabel = async (tx: DailyReportDb, hubId: number, labelId: number) => {
|
|
1126
|
+
await tx.delete(DailyReportHub_Label).where(and(eq(DailyReportHub_Label.hubId, hubId), eq(DailyReportHub_Label.labelId, labelId)))
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// --- Service Layer ---
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* Logically deletes a daily report.
|
|
1133
|
+
* 日報を論理削除する。
|
|
1134
|
+
*/
|
|
1135
|
+
const deleteDailyReport = async (reportHubId: number, userId: number, clientTempId: string): Promise<void> => {
|
|
1136
|
+
const report = await findDailyReportHubById(db, reportHubId)
|
|
1137
|
+
if (!report.length || report[0].deletedAt) {
|
|
1138
|
+
throw new Error("Not Found")
|
|
1139
|
+
}
|
|
1140
|
+
if (report[0].userId !== userId) {
|
|
1141
|
+
throw new Error("Unauthorized")
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
await updateDailyReportHub(db, reportHubId, {
|
|
1145
|
+
deletedAt: new Date(),
|
|
1146
|
+
deletedBy: String(userId),
|
|
1147
|
+
updatedAt: new Date(),
|
|
1148
|
+
updatedBy: String(userId),
|
|
1149
|
+
})
|
|
1150
|
+
|
|
1151
|
+
// キャッシュ無効化
|
|
1152
|
+
sqlResultCache.invalidatePrefix(`daily-report:detail:${reportHubId}:user:`)
|
|
1153
|
+
sqlResultCache.invalidatePrefix(DAILY_REPORT_BUSINESS_DATE_CACHE_PREFIX)
|
|
1154
|
+
// 削除されたレポートは全ユーザーの一覧から消えるため、全ユーザーの IDs キャッシュを invalidate
|
|
1155
|
+
sqlResultCache.invalidatePrefix(DAILY_REPORT_IDS_CACHE_KEY)
|
|
1156
|
+
// ❗ 順序重要: invalidatePrefix → incrementRedisEpoch → publishToSseStream
|
|
1157
|
+
await incrementRedisEpoch(DAILY_REPORT_IDS_EPOCH_KEY)
|
|
1158
|
+
if (report[0].businessDate) {
|
|
1159
|
+
const normalizedDate = normalizeBusinessDateKey(formatDateValue(report[0].businessDate, "YYYY-MM-DD"))
|
|
1160
|
+
if (normalizedDate) {
|
|
1161
|
+
await incrementRedisEpoch(`${DAILY_REPORT_DATE_EPOCH_PREFIX}${normalizedDate}`)
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
await publishToSseStream(
|
|
1166
|
+
reportDeleteMessageSchema.parse({
|
|
1167
|
+
type: "report-delete",
|
|
1168
|
+
reportHubId: reportHubId,
|
|
1169
|
+
clientTempId,
|
|
1170
|
+
}),
|
|
1171
|
+
"deleteDailyReport",
|
|
1172
|
+
)
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Updates an existing daily report.
|
|
1177
|
+
* 日報を更新する。
|
|
1178
|
+
*/
|
|
1179
|
+
const updateDailyReport = async (reportHubId: number, userId: number, data: { title?: string; content?: string }, clientTempId: string): Promise<void> => {
|
|
1180
|
+
const report = await findDailyReportHubById(db, reportHubId)
|
|
1181
|
+
if (!report.length || report[0].deletedAt) {
|
|
1182
|
+
throw new Error("Not Found")
|
|
1183
|
+
}
|
|
1184
|
+
if (report[0].userId !== userId) {
|
|
1185
|
+
throw new Error("Unauthorized")
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
await db.transaction(async (tx) => {
|
|
1189
|
+
if (data.title !== undefined) {
|
|
1190
|
+
await updateDailyReportHub(tx, reportHubId, {
|
|
1191
|
+
title: data.title,
|
|
1192
|
+
updatedAt: new Date(),
|
|
1193
|
+
updatedBy: String(userId),
|
|
1194
|
+
})
|
|
1195
|
+
}
|
|
1196
|
+
if (data.content !== undefined) {
|
|
1197
|
+
await updateDailyReportInternal(tx, reportHubId, {
|
|
1198
|
+
body: data.content,
|
|
1199
|
+
updatedAt: new Date(),
|
|
1200
|
+
updatedBy: String(userId),
|
|
1201
|
+
})
|
|
1202
|
+
}
|
|
1203
|
+
})
|
|
1204
|
+
|
|
1205
|
+
// キャッシュ無効化
|
|
1206
|
+
sqlResultCache.invalidatePrefix(`daily-report:detail:${reportHubId}:user:`)
|
|
1207
|
+
sqlResultCache.invalidatePrefix(DAILY_REPORT_BUSINESS_DATE_CACHE_PREFIX)
|
|
1208
|
+
|
|
1209
|
+
const fullDetail = await getDailyReportDetailById(reportHubId, userId, { forceRefresh: true })
|
|
1210
|
+
if (fullDetail) {
|
|
1211
|
+
// 下書き判定
|
|
1212
|
+
const draftLabelId = await getDraftLabelId()
|
|
1213
|
+
const isDraft = draftLabelId ? fullDetail.labels.some((l) => l.id === draftLabelId) : false
|
|
1214
|
+
await publishToSseStream(
|
|
1215
|
+
reportUpdateMessageSchema.parse({
|
|
1216
|
+
type: "report-update",
|
|
1217
|
+
reportHubId: fullDetail.reportHubId,
|
|
1218
|
+
report: fullDetail,
|
|
1219
|
+
clientTempId,
|
|
1220
|
+
recipientRawUserId: isDraft ? userId : undefined,
|
|
1221
|
+
}),
|
|
1222
|
+
"updateDailyReport",
|
|
1223
|
+
)
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* Publishes a draft daily report.
|
|
1229
|
+
* 日報を公開する(下書きラベルを削除)。
|
|
1230
|
+
*/
|
|
1231
|
+
const publishDailyReport = async (reportHubId: number, userId: number, clientTempId: string): Promise<DailyReportDetail | null> => {
|
|
1232
|
+
const report = await findDailyReportHubById(db, reportHubId)
|
|
1233
|
+
if (!report.length) {
|
|
1234
|
+
throw new Error("Not Found")
|
|
1235
|
+
}
|
|
1236
|
+
if (report[0].userId !== userId) {
|
|
1237
|
+
throw new Error("Unauthorized")
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
const draftLabelId = await getDraftLabelId()
|
|
1241
|
+
if (!draftLabelId) return null
|
|
1242
|
+
|
|
1243
|
+
await deleteDailyReportLabel(db, reportHubId, draftLabelId)
|
|
1244
|
+
|
|
1245
|
+
// キャッシュ無効化
|
|
1246
|
+
sqlResultCache.invalidatePrefix(`daily-report:detail:${reportHubId}:user:`)
|
|
1247
|
+
sqlResultCache.invalidatePrefix(DAILY_REPORT_BUSINESS_DATE_CACHE_PREFIX)
|
|
1248
|
+
// 公開によりドラフトが全ユーザーに可視化されるため、全ユーザーの IDs キャッシュを invalidate
|
|
1249
|
+
sqlResultCache.invalidatePrefix(DAILY_REPORT_IDS_CACHE_KEY)
|
|
1250
|
+
// ❗ 順序重要: invalidatePrefix → incrementRedisEpoch → publishToSseStream
|
|
1251
|
+
await incrementRedisEpoch(DAILY_REPORT_IDS_EPOCH_KEY)
|
|
1252
|
+
if (report[0].businessDate) {
|
|
1253
|
+
const normalizedDate = normalizeBusinessDateKey(formatDateValue(report[0].businessDate, "YYYY-MM-DD"))
|
|
1254
|
+
if (normalizedDate) {
|
|
1255
|
+
await incrementRedisEpoch(`${DAILY_REPORT_DATE_EPOCH_PREFIX}${normalizedDate}`)
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// ユーザーごとの営業日別一覧キャッシュも無効化
|
|
1260
|
+
const normalizedBusinessDate = normalizeBusinessDateKey(formatDateValue(report[0].businessDate, "YYYY-MM-DD"))
|
|
1261
|
+
if (normalizedBusinessDate) {
|
|
1262
|
+
sqlResultCache.invalidate(`daily-report:date:${normalizedBusinessDate}:user:${userId}`)
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
const fullDetail = await getDailyReportDetailById(reportHubId, userId, { forceRefresh: true })
|
|
1266
|
+
if (fullDetail) {
|
|
1267
|
+
await publishToSseStream(
|
|
1268
|
+
reportPublishMessageSchema.parse({
|
|
1269
|
+
type: "report-publish",
|
|
1270
|
+
reportHubId: fullDetail.reportHubId,
|
|
1271
|
+
report: fullDetail,
|
|
1272
|
+
clientTempId,
|
|
1273
|
+
}),
|
|
1274
|
+
"publishDailyReport",
|
|
1275
|
+
)
|
|
1276
|
+
}
|
|
1277
|
+
return fullDetail ?? null
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
return {
|
|
1281
|
+
// 定数 (アプリ側の互換 export 用)
|
|
1282
|
+
streamKey: DAILY_REPORT_SSE_STREAM_KEY,
|
|
1283
|
+
streamMaxLen: DAILY_REPORT_SSE_STREAM_MAXLEN,
|
|
1284
|
+
// ユーザー解決
|
|
1285
|
+
getUserIdByExternalId,
|
|
1286
|
+
// 参照系
|
|
1287
|
+
getDailyReportIdsByExternalId,
|
|
1288
|
+
getDailyReportsByBusinessDate,
|
|
1289
|
+
getDailyReportsByBusinessDateByExternalId,
|
|
1290
|
+
getDailyReportDetailById,
|
|
1291
|
+
getDailyReportDetailByIdByExternalId,
|
|
1292
|
+
getDraftLabelId,
|
|
1293
|
+
// 更新系
|
|
1294
|
+
setStarStatus,
|
|
1295
|
+
setReadStatus,
|
|
1296
|
+
addComment,
|
|
1297
|
+
deleteComment,
|
|
1298
|
+
createDailyReport,
|
|
1299
|
+
updateDailyReport,
|
|
1300
|
+
publishDailyReport,
|
|
1301
|
+
deleteDailyReport,
|
|
1302
|
+
// リポジトリヘルパー (統合テスト等からの直接利用向け)
|
|
1303
|
+
findDailyReportHubById,
|
|
1304
|
+
updateDailyReportHub,
|
|
1305
|
+
updateDailyReportInternal,
|
|
1306
|
+
deleteDailyReportLabel,
|
|
1307
|
+
}
|
|
1308
|
+
}
|