@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,543 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Router loader/action factories for the daily-report HTTP surface.
|
|
3
|
+
* 日報 HTTP サーフェス向けの React Router loader / action 工場。
|
|
4
|
+
*
|
|
5
|
+
* - index.loader: 画面ルートの認証 + 難読化ユーザー ID 解決
|
|
6
|
+
* - api.loader / api.action: `:endpoint` パラメータ式の認証付き API ルーター
|
|
7
|
+
* - sse.loader: Redis Streams ベースのリアルタイム更新 SSE エンドポイント
|
|
8
|
+
*/
|
|
9
|
+
import { normalizeBusinessDateKey } from "../shared/business-date"
|
|
10
|
+
import { createLogger, type DailyReportLogger, LogLevel } from "../shared/logger"
|
|
11
|
+
import { dailyReportSseMessageSchema } from "../shared/sse-schema"
|
|
12
|
+
import type { DailyReportAuthenticate, DailyReportEncodeUserId, DailyReportRedisProvider } from "./ports"
|
|
13
|
+
import { jsonResponseWithETag } from "./response"
|
|
14
|
+
import type { DailyReportService } from "./service"
|
|
15
|
+
import type { DailyReportSseReader, StreamEntry } from "./sse-reader"
|
|
16
|
+
import { isStreamIdLte } from "./sse-reader"
|
|
17
|
+
|
|
18
|
+
type LoaderArgs = { request: Request; params: Record<string, string | undefined> }
|
|
19
|
+
|
|
20
|
+
/** `data()` 相当の JSON レスポンス生成 (react-router 非依存)。 */
|
|
21
|
+
const jsonData = (payload: unknown, init?: { status?: number }): Response =>
|
|
22
|
+
new Response(JSON.stringify(payload), {
|
|
23
|
+
status: init?.status ?? 200,
|
|
24
|
+
headers: { "Content-Type": "application/json" },
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export type DailyReportHandlersConfig = {
|
|
28
|
+
/** リクエスト認証ポート。 */
|
|
29
|
+
authenticate: DailyReportAuthenticate
|
|
30
|
+
/** データアクセスサービス。 */
|
|
31
|
+
service: DailyReportService
|
|
32
|
+
/** 内部数値 ID の難読化ポート。 */
|
|
33
|
+
encodeUserId: DailyReportEncodeUserId
|
|
34
|
+
/** SSE 用 redis プロバイダー (catch-up の xRange / xRevRange に使用)。 */
|
|
35
|
+
redis?: DailyReportRedisProvider
|
|
36
|
+
/** SSE Fan-Out 共有リーダー。 */
|
|
37
|
+
sseReader: DailyReportSseReader
|
|
38
|
+
/** SSE Redis Stream キー。 */
|
|
39
|
+
streamKey: string
|
|
40
|
+
/** 未ログイン時のリダイレクト先 (index.loader 用、既定 "/auth/login")。 */
|
|
41
|
+
loginRedirectPath?: string
|
|
42
|
+
/** ロガー (既定は console ベース)。 */
|
|
43
|
+
logger?: DailyReportLogger
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Creates the daily-report loaders/actions bound to the injected dependencies.
|
|
48
|
+
* 注入依存に束縛された日報 loader / action 群を生成する処理。
|
|
49
|
+
*/
|
|
50
|
+
export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
|
|
51
|
+
const { authenticate, service, encodeUserId, redis, sseReader, streamKey } = config
|
|
52
|
+
const loginRedirectPath = config.loginRedirectPath ?? "/auth/login"
|
|
53
|
+
const apiLogger = config.logger ?? createLogger(LogLevel.ERROR, "[DailyReportAPI]")
|
|
54
|
+
const sseLogger = config.logger ?? createLogger(LogLevel.INFO, "[DailyReportSSE]")
|
|
55
|
+
|
|
56
|
+
// ---------------- index (画面ルート) ----------------
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Document loader that authenticates and resolves the obfuscated internal user id.
|
|
60
|
+
* 認証と難読化済み内部ユーザー ID の解決を行うドキュメントローダー。
|
|
61
|
+
*/
|
|
62
|
+
const indexLoader = async ({ request }: LoaderArgs) => {
|
|
63
|
+
const { user, cookie } = await authenticate(request, { failureRedirect: loginRedirectPath })
|
|
64
|
+
const headers = new Headers()
|
|
65
|
+
if (cookie) {
|
|
66
|
+
headers.append("Set-Cookie", cookie)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let userId: number | null = null
|
|
70
|
+
if (user) {
|
|
71
|
+
userId = await service.getUserIdByExternalId(user.id)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const hashedUserId = userId ? encodeUserId(userId) : null
|
|
75
|
+
|
|
76
|
+
return { data: { user, userId: hashedUserId }, headers }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------- api (:endpoint ルーター) ----------------
|
|
80
|
+
|
|
81
|
+
type User = { id: string }
|
|
82
|
+
type EndpointHandler = (url: URL, cookie: string | null, request: Request, user: User) => Promise<Response>
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Handlers for each API endpoint.
|
|
86
|
+
* 各 API エンドポイントのハンドラー定義。
|
|
87
|
+
*/
|
|
88
|
+
const endpointHandlers: Record<string, EndpointHandler> = {
|
|
89
|
+
/**
|
|
90
|
+
* Retrieves daily reports for a specific business date.
|
|
91
|
+
* 指定された営業日の日報一覧を取得する。
|
|
92
|
+
*/
|
|
93
|
+
"business-date": async (url, cookie, request, user) => {
|
|
94
|
+
const normalizedBusinessDate = normalizeBusinessDateKey(url.searchParams.get("businessDate"))
|
|
95
|
+
const forceRefresh = url.searchParams.get("forceRefresh") === "true"
|
|
96
|
+
|
|
97
|
+
if (!normalizedBusinessDate) {
|
|
98
|
+
return jsonResponseWithETag(request, cookie, { error: { message: "Invalid business date" } }, 400)
|
|
99
|
+
}
|
|
100
|
+
const reports = await service.getDailyReportsByBusinessDateByExternalId(normalizedBusinessDate, user.id, { forceRefresh })
|
|
101
|
+
return jsonResponseWithETag(request, cookie, { businessDate: normalizedBusinessDate, reports }, 200)
|
|
102
|
+
},
|
|
103
|
+
/**
|
|
104
|
+
* Retrieves a list of all daily report IDs.
|
|
105
|
+
* 全ての日報 ID の一覧を取得する。
|
|
106
|
+
*/
|
|
107
|
+
ids: async (url, cookie, request, user) => {
|
|
108
|
+
const forceRefresh = url.searchParams.get("forceRefresh") === "true"
|
|
109
|
+
const dailyReportIds = await service.getDailyReportIdsByExternalId(user.id, { forceRefresh })
|
|
110
|
+
return jsonResponseWithETag(request, cookie, { ids: dailyReportIds }, 200)
|
|
111
|
+
},
|
|
112
|
+
/**
|
|
113
|
+
* Retrieves details for a specific daily report.
|
|
114
|
+
* 指定された日報の詳細情報を取得する。
|
|
115
|
+
*/
|
|
116
|
+
report: async (url, cookie, request, user) => {
|
|
117
|
+
const param = url.searchParams.get("reportHubId")
|
|
118
|
+
const forceRefresh = url.searchParams.get("forceRefresh") === "true"
|
|
119
|
+
const parsedId = param ? Number.parseInt(param, 10) : NaN
|
|
120
|
+
|
|
121
|
+
if (!Number.isFinite(parsedId) || parsedId <= 0) {
|
|
122
|
+
return jsonResponseWithETag(request, cookie, { error: { message: "Invalid reportHubId" } }, 400)
|
|
123
|
+
}
|
|
124
|
+
const detail = await service.getDailyReportDetailByIdByExternalId(parsedId, user.id, { snapshot: true, forceRefresh })
|
|
125
|
+
if (!detail) {
|
|
126
|
+
return jsonResponseWithETag(request, cookie, { error: { message: "Report not found" } }, 404)
|
|
127
|
+
}
|
|
128
|
+
return jsonResponseWithETag(request, cookie, { report: detail }, 200)
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Routes authenticated daily report API requests by endpoint.
|
|
134
|
+
* エンドポイントごとに認証済みの日報 API リクエストを振り分ける。
|
|
135
|
+
*/
|
|
136
|
+
const apiLoader = async ({ request, params }: LoaderArgs) => {
|
|
137
|
+
const { user, cookie } = await authenticate(request, { failureRedirect: null })
|
|
138
|
+
const sanitizedCookie = cookie ?? null
|
|
139
|
+
|
|
140
|
+
if (!user) {
|
|
141
|
+
return jsonResponseWithETag(request, sanitizedCookie, { error: { message: "Unauthorized" } }, 401)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (request.method !== "GET") {
|
|
145
|
+
return jsonResponseWithETag(request, sanitizedCookie, { error: { message: "Method not allowed" } }, 405)
|
|
146
|
+
}
|
|
147
|
+
const endpoint = params.endpoint ?? ""
|
|
148
|
+
const handler = endpointHandlers[endpoint]
|
|
149
|
+
if (!handler) {
|
|
150
|
+
return jsonResponseWithETag(request, sanitizedCookie, { error: { message: "Unknown endpoint" } }, 404)
|
|
151
|
+
}
|
|
152
|
+
const startTime = Date.now()
|
|
153
|
+
try {
|
|
154
|
+
const url = new URL(request.url)
|
|
155
|
+
return await handler(url, sanitizedCookie, request, user)
|
|
156
|
+
} catch (error) {
|
|
157
|
+
const elapsed = Date.now() - startTime
|
|
158
|
+
const err = error instanceof Error ? error : new Error(String(error))
|
|
159
|
+
apiLogger.error(`500 endpoint=${endpoint} elapsed=${elapsed}ms name=${err.name} code=${"code" in err ? (err as { code: unknown }).code : "N/A"} message=${err.message}`)
|
|
160
|
+
apiLogger.error("Stack:", err.stack)
|
|
161
|
+
return jsonResponseWithETag(request, sanitizedCookie, { error: { message: "Internal Server Error" } }, 500)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Handles data mutations for daily reports.
|
|
167
|
+
* 日報データの変更操作を処理するアクション。
|
|
168
|
+
*/
|
|
169
|
+
const apiAction = async ({ request, params }: LoaderArgs) => {
|
|
170
|
+
const { user } = await authenticate(request, { failureRedirect: null })
|
|
171
|
+
if (!user) {
|
|
172
|
+
return jsonData({ error: "Unauthorized" }, { status: 401 })
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const endpoint = params.endpoint
|
|
176
|
+
if (endpoint !== "action") {
|
|
177
|
+
return jsonData({ error: "Unknown endpoint" }, { status: 404 })
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const userId = await service.getUserIdByExternalId(user.id)
|
|
181
|
+
if (!userId) {
|
|
182
|
+
return jsonData({ error: "User not found" }, { status: 404 })
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const formData = await request.formData()
|
|
186
|
+
const intent = formData.get("intent")
|
|
187
|
+
const reportHubIdRaw = formData.get("reportHubId")
|
|
188
|
+
const reportHubId = reportHubIdRaw ? Number(reportHubIdRaw) : NaN
|
|
189
|
+
const businessDate = formData.get("businessDate") as string | null
|
|
190
|
+
const operationTimestamp = Number(formData.get("operationTimestamp"))
|
|
191
|
+
const clientTempId = formData.get("clientTempId") as string | null
|
|
192
|
+
|
|
193
|
+
if (!clientTempId) {
|
|
194
|
+
return jsonData({ error: "clientTempId required" }, { status: 400 })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (intent !== "create" && (!reportHubId || Number.isNaN(reportHubId))) {
|
|
198
|
+
return jsonData({ error: "Invalid reportHubId" }, { status: 400 })
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
switch (intent) {
|
|
202
|
+
case "create": {
|
|
203
|
+
if (!businessDate) {
|
|
204
|
+
return jsonData({ error: "businessDate required" }, { status: 400 })
|
|
205
|
+
}
|
|
206
|
+
const newReport = await service.createDailyReport(userId, businessDate, clientTempId)
|
|
207
|
+
return jsonData({
|
|
208
|
+
status: "OK",
|
|
209
|
+
intent: "create",
|
|
210
|
+
report: newReport,
|
|
211
|
+
reportHubId: String(newReport.reportHubId),
|
|
212
|
+
clientTempId, // Echo back for validation
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
case "update": {
|
|
216
|
+
const title = formData.get("title") as string | undefined
|
|
217
|
+
const content = formData.get("content") as string | undefined
|
|
218
|
+
try {
|
|
219
|
+
await service.updateDailyReport(reportHubId, userId, { title, content }, clientTempId)
|
|
220
|
+
return jsonData({ status: "OK", intent: "update", reportHubId: String(reportHubId), clientTempId })
|
|
221
|
+
} catch (e: unknown) {
|
|
222
|
+
if (e instanceof Error) {
|
|
223
|
+
if (e.message === "Unauthorized") {
|
|
224
|
+
return jsonData({ error: "Unauthorized" }, { status: 403 })
|
|
225
|
+
}
|
|
226
|
+
if (e.message === "Not Found") {
|
|
227
|
+
return jsonData({ error: "Report not found" }, { status: 404 })
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
throw e
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
case "publish": {
|
|
234
|
+
try {
|
|
235
|
+
const publishedReport = await service.publishDailyReport(reportHubId, userId, clientTempId)
|
|
236
|
+
return jsonData({ status: "OK", intent: "publish", reportHubId: String(reportHubId), clientTempId, report: publishedReport })
|
|
237
|
+
} catch (e: unknown) {
|
|
238
|
+
if (e instanceof Error) {
|
|
239
|
+
if (e.message === "Unauthorized") {
|
|
240
|
+
return jsonData({ error: "Unauthorized" }, { status: 403 })
|
|
241
|
+
}
|
|
242
|
+
if (e.message === "Not Found") {
|
|
243
|
+
return jsonData({ error: "Report not found" }, { status: 404 })
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
throw e
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
case "delete": {
|
|
250
|
+
try {
|
|
251
|
+
await service.deleteDailyReport(reportHubId, userId, clientTempId)
|
|
252
|
+
return jsonData({
|
|
253
|
+
status: "OK",
|
|
254
|
+
intent: "delete",
|
|
255
|
+
reportHubId: String(reportHubId),
|
|
256
|
+
clientTempId, // Echo back for validation even on delete
|
|
257
|
+
})
|
|
258
|
+
} catch (e: unknown) {
|
|
259
|
+
if (e instanceof Error) {
|
|
260
|
+
if (e.message === "Unauthorized") {
|
|
261
|
+
return jsonData({ error: "Unauthorized" }, { status: 403 })
|
|
262
|
+
}
|
|
263
|
+
if (e.message === "Not Found") {
|
|
264
|
+
return jsonData({ error: "Report not found" }, { status: 404 })
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
throw e
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
case "toggleStar": {
|
|
271
|
+
const isStarredRaw = formData.get("isStarred")
|
|
272
|
+
if (isStarredRaw === null) {
|
|
273
|
+
return jsonData({ error: "isStarred required" }, { status: 400 })
|
|
274
|
+
}
|
|
275
|
+
const isStarred = isStarredRaw === "true"
|
|
276
|
+
const updatedStatus = await service.setStarStatus(userId, reportHubId, businessDate, isStarred, clientTempId)
|
|
277
|
+
// クライアントは isStarred/isRead のみ参照する。生内部 ID (userId/created_by/updated_by) を
|
|
278
|
+
// 含む行全体は返さず、必要なフラグだけに絞る (内部 ID の自己開示防止)。
|
|
279
|
+
return jsonData({ status: "OK", intent: "toggleStar", updatedStatus: { isStarred: updatedStatus.isStarred, isRead: updatedStatus.isRead }, operationTimestamp, reportHubId: String(reportHubId), clientTempId })
|
|
280
|
+
}
|
|
281
|
+
case "toggleRead": {
|
|
282
|
+
const isReadRaw = formData.get("isRead")
|
|
283
|
+
if (isReadRaw === null) {
|
|
284
|
+
return jsonData({ error: "isRead required" }, { status: 400 })
|
|
285
|
+
}
|
|
286
|
+
const isRead = isReadRaw === "true"
|
|
287
|
+
const updatedStatus = await service.setReadStatus(userId, reportHubId, businessDate, isRead, clientTempId)
|
|
288
|
+
// クライアントは isStarred/isRead のみ参照する (内部 ID の自己開示防止)。
|
|
289
|
+
return jsonData({ status: "OK", intent: "toggleRead", updatedStatus: { isStarred: updatedStatus.isStarred, isRead: updatedStatus.isRead }, operationTimestamp, reportHubId: String(reportHubId), clientTempId })
|
|
290
|
+
}
|
|
291
|
+
case "addComment": {
|
|
292
|
+
const content = formData.get("content") as string
|
|
293
|
+
if (!content) {
|
|
294
|
+
return jsonData({ error: "Content required" }, { status: 400 })
|
|
295
|
+
}
|
|
296
|
+
const newComment = await service.addComment(userId, reportHubId, content, businessDate, clientTempId)
|
|
297
|
+
const safeComment = { ...newComment, userId: newComment.userId }
|
|
298
|
+
return jsonData({ status: "OK", intent: "addComment", newComment: safeComment, reportHubId: String(reportHubId), clientTempId })
|
|
299
|
+
}
|
|
300
|
+
case "deleteComment": {
|
|
301
|
+
const commentId = Number(formData.get("commentId"))
|
|
302
|
+
if (!commentId || Number.isNaN(commentId)) {
|
|
303
|
+
return jsonData({ error: "Invalid commentId" }, { status: 400 })
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
await service.deleteComment(userId, reportHubId, commentId, businessDate, clientTempId)
|
|
307
|
+
return jsonData({ status: "OK", reportHubId: String(reportHubId), deletedCommentId: String(commentId), clientTempId })
|
|
308
|
+
} catch (e: unknown) {
|
|
309
|
+
if (e instanceof Error) {
|
|
310
|
+
if (e.message === "Unauthorized") {
|
|
311
|
+
return jsonData({ error: "Unauthorized" }, { status: 403 })
|
|
312
|
+
}
|
|
313
|
+
if (e.message === "Not Found") {
|
|
314
|
+
return jsonData({ error: "Comment not found" }, { status: 404 })
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
throw e
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
default:
|
|
321
|
+
return jsonData({ error: "Invalid intent" }, { status: 400 })
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---------------- sse (リアルタイム更新) ----------------
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* SSE endpoint for real-time daily report updates via Redis Streams.
|
|
329
|
+
* Redis Streams を使用した日報リアルタイム更新の SSE エンドポイント。
|
|
330
|
+
*/
|
|
331
|
+
const sseLoader = async ({ request, params }: LoaderArgs) => {
|
|
332
|
+
// 認証チェック
|
|
333
|
+
const { user } = await authenticate(request, { failureRedirect: null })
|
|
334
|
+
if (!user) {
|
|
335
|
+
return new Response("Unauthorized", { status: 401 })
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// 内部ユーザー ID の解決
|
|
339
|
+
const internalUserId = await service.getUserIdByExternalId(user.id)
|
|
340
|
+
if (!internalUserId) {
|
|
341
|
+
return new Response("Forbidden", { status: 403 })
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// エンドポイントの検証
|
|
345
|
+
if (params.endpoint !== "updates") {
|
|
346
|
+
return new Response("Not Found", { status: 404 })
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// URL から lastEventId を取得 (再接続時の catch-up 用)
|
|
350
|
+
// クエリパラメータ優先、EventSource 自動再接続時の Last-Event-ID ヘッダーにフォールバック
|
|
351
|
+
const url = new URL(request.url)
|
|
352
|
+
const lastEventId = url.searchParams.get("lastEventId") || request.headers.get("Last-Event-ID")
|
|
353
|
+
|
|
354
|
+
const encoder = new TextEncoder()
|
|
355
|
+
let controllerRef: ReadableStreamDefaultController<Uint8Array> | null = null
|
|
356
|
+
let keepAliveInterval: ReturnType<typeof setInterval> | null = null
|
|
357
|
+
let unsubscribe: (() => void) | null = null
|
|
358
|
+
let isCleaningUp = false
|
|
359
|
+
|
|
360
|
+
// クリーンアップ処理
|
|
361
|
+
const cleanup = () => {
|
|
362
|
+
if (isCleaningUp) return
|
|
363
|
+
isCleaningUp = true
|
|
364
|
+
|
|
365
|
+
// keep-alive タイマー停止
|
|
366
|
+
if (keepAliveInterval) {
|
|
367
|
+
clearInterval(keepAliveInterval)
|
|
368
|
+
keepAliveInterval = null
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Fan-Out 購読解除 (entry / error 両リスナーを解除)
|
|
372
|
+
if (unsubscribe) {
|
|
373
|
+
unsubscribe()
|
|
374
|
+
unsubscribe = null
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// SSE ストリームの終了
|
|
378
|
+
if (controllerRef) {
|
|
379
|
+
try {
|
|
380
|
+
if (controllerRef.desiredSize !== null) {
|
|
381
|
+
controllerRef.close()
|
|
382
|
+
}
|
|
383
|
+
} catch (_e) {
|
|
384
|
+
// ストリームが既に閉じている場合は無視
|
|
385
|
+
} finally {
|
|
386
|
+
controllerRef = null
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
isCleaningUp = false
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Processes a single Redis Stream entry and sends it to the SSE client.
|
|
395
|
+
* Redis Stream のエントリを処理し、SSE クライアントに送信する。
|
|
396
|
+
*/
|
|
397
|
+
const processEntry = (entryId: string, fields: Record<string, string>) => {
|
|
398
|
+
// data フィールドの存在チェック
|
|
399
|
+
if (!fields.data) return
|
|
400
|
+
|
|
401
|
+
try {
|
|
402
|
+
const raw = JSON.parse(fields.data)
|
|
403
|
+
const result = dailyReportSseMessageSchema.safeParse(raw)
|
|
404
|
+
if (!result.success) {
|
|
405
|
+
sseLogger.error("SSE message validation failed:", result.error.format())
|
|
406
|
+
return // Fail-Closed: 不正なメッセージは送信しない
|
|
407
|
+
}
|
|
408
|
+
const parsed = result.data
|
|
409
|
+
|
|
410
|
+
// recipientRawUserId によるフィルタリング
|
|
411
|
+
const recipientRawUserId = typeof raw.recipientRawUserId === "number" ? raw.recipientRawUserId : undefined
|
|
412
|
+
|
|
413
|
+
if (parsed.type === "status-update") {
|
|
414
|
+
if (recipientRawUserId === undefined || recipientRawUserId !== internalUserId) {
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
} else if (recipientRawUserId !== undefined && recipientRawUserId !== internalUserId) {
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// recipientRawUserId をクライアントに送信しない (内部 ID 漏洩防止)
|
|
422
|
+
let sanitizedMessage: string
|
|
423
|
+
if ("recipientRawUserId" in raw) {
|
|
424
|
+
const { recipientRawUserId: _, ...rest } = raw
|
|
425
|
+
sanitizedMessage = JSON.stringify(rest)
|
|
426
|
+
} else {
|
|
427
|
+
sanitizedMessage = fields.data
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// SSE フォーマットで送信 (id フィールド付き)
|
|
431
|
+
if (controllerRef && controllerRef.desiredSize !== null) {
|
|
432
|
+
controllerRef.enqueue(encoder.encode(`id: ${entryId}\ndata: ${sanitizedMessage}\n\n`))
|
|
433
|
+
}
|
|
434
|
+
} catch (e) {
|
|
435
|
+
sseLogger.error(`[SSE:${internalUserId}] processEntry error:`, e)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const stream = new ReadableStream({
|
|
440
|
+
async start(controller) {
|
|
441
|
+
// 1. controllerRef 設定
|
|
442
|
+
controllerRef = controller
|
|
443
|
+
|
|
444
|
+
// 2. Keep-Alive: 5 秒ごとにコメントを送信して接続維持
|
|
445
|
+
keepAliveInterval = setInterval(() => {
|
|
446
|
+
try {
|
|
447
|
+
if (controllerRef && controllerRef.desiredSize !== null) {
|
|
448
|
+
controllerRef.enqueue(encoder.encode(": keep-alive\n\n"))
|
|
449
|
+
} else {
|
|
450
|
+
cleanup()
|
|
451
|
+
}
|
|
452
|
+
} catch (_e) {
|
|
453
|
+
cleanup()
|
|
454
|
+
}
|
|
455
|
+
}, 5000)
|
|
456
|
+
|
|
457
|
+
// 3. クライアント切断時のクリーンアップ (全 await の前に登録)
|
|
458
|
+
request.signal.addEventListener("abort", () => {
|
|
459
|
+
cleanup()
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
// catch-up の基準 ID (Fan-Out エントリとの重複排除に使用)
|
|
463
|
+
let lastProcessedId = lastEventId || "0-0"
|
|
464
|
+
|
|
465
|
+
// 4. Fan-Out 共有リーダーを購読 (catch-up より先に登録して取りこぼしを防止)
|
|
466
|
+
unsubscribe = sseReader.subscribe(
|
|
467
|
+
(entry: StreamEntry) => {
|
|
468
|
+
// catch-up 済みエントリはスキップ (重複排除)
|
|
469
|
+
if (isStreamIdLte(entry.id, lastProcessedId)) return
|
|
470
|
+
lastProcessedId = entry.id
|
|
471
|
+
processEntry(entry.id, entry.message)
|
|
472
|
+
},
|
|
473
|
+
(err: Error) => {
|
|
474
|
+
sseLogger.error(`[SSE:${internalUserId}] Fan-Out reader error:`, err)
|
|
475
|
+
cleanup()
|
|
476
|
+
},
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
// 5. 初回接続時: 最新 Stream ID を取得して connected の id: に使用
|
|
480
|
+
// (再接続時は lastEventId が既にあるためスキップ)
|
|
481
|
+
if (!lastEventId) {
|
|
482
|
+
try {
|
|
483
|
+
const client = await redis?.getClient()
|
|
484
|
+
if (client) {
|
|
485
|
+
const latest = await client.xRevRange(streamKey, "+", "-", { COUNT: 1 })
|
|
486
|
+
// Fan-Out が既に lastProcessedId を進めている場合は巻き戻さない
|
|
487
|
+
if (latest.length > 0 && !isStreamIdLte(latest[0].id, lastProcessedId)) {
|
|
488
|
+
lastProcessedId = latest[0].id
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
} catch {
|
|
492
|
+
/* 取得失敗時は現在の lastProcessedId を維持 */
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// 6. 接続完了通知 (id: 付きで再接続時の catch-up アンカーを提供)
|
|
497
|
+
if (controllerRef && controllerRef.desiredSize !== null) {
|
|
498
|
+
const connectedPayload = `data: ${JSON.stringify({ type: "connected" })}\n\n`
|
|
499
|
+
const sseMessage = lastProcessedId !== "0-0" ? `id: ${lastProcessedId}\n${connectedPayload}` : connectedPayload
|
|
500
|
+
controllerRef.enqueue(encoder.encode(sseMessage))
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// 7. catch-up: lastEventId が指定されている場合、それ以降のエントリを一括取得
|
|
504
|
+
if (lastEventId) {
|
|
505
|
+
try {
|
|
506
|
+
const client = await redis?.getClient()
|
|
507
|
+
if (client) {
|
|
508
|
+
const catchUpEntries = await client.xRange(streamKey, lastEventId, "+", { COUNT: 1000 })
|
|
509
|
+
for (const entry of catchUpEntries) {
|
|
510
|
+
// xRange は inclusive なので lastEventId 自身はスキップ
|
|
511
|
+
if (entry.id === lastEventId) continue
|
|
512
|
+
// Fan-Out が xRange await 中に処理済みのエントリはスキップ (重複排除)
|
|
513
|
+
if (isStreamIdLte(entry.id, lastProcessedId)) continue
|
|
514
|
+
lastProcessedId = entry.id
|
|
515
|
+
processEntry(entry.id, entry.message as Record<string, string>)
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
} catch (e) {
|
|
519
|
+
sseLogger.error(`[SSE:${internalUserId}] catch-up xRange error:`, e)
|
|
520
|
+
// catch-up 失敗時は Fan-Out のみで継続
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
},
|
|
524
|
+
cancel() {
|
|
525
|
+
cleanup()
|
|
526
|
+
},
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
return new Response(stream, {
|
|
530
|
+
headers: {
|
|
531
|
+
"Content-Type": "text/event-stream",
|
|
532
|
+
"Cache-Control": "no-cache, no-transform",
|
|
533
|
+
Connection: "keep-alive",
|
|
534
|
+
},
|
|
535
|
+
})
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return {
|
|
539
|
+
index: { loader: indexLoader },
|
|
540
|
+
api: { loader: apiLoader, action: apiAction },
|
|
541
|
+
sse: { loader: sseLoader },
|
|
542
|
+
}
|
|
543
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DI ports (structural interfaces) consumed by the daily-report server layer.
|
|
3
|
+
* daily-report サーバー層が消費する DI ポート (構造的インターフェース) 群。
|
|
4
|
+
*
|
|
5
|
+
* redis / 認証 / ユーザー解決 / ID 難読化はすべてアプリ実装を注入する。
|
|
6
|
+
* redis の型は `redis` パッケージへ依存しないよう、使用する最小面のみ構造定義する。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Redis Stream の 1 エントリ (xRead / xRange の結果要素)。 */
|
|
10
|
+
export type RedisStreamMessage = {
|
|
11
|
+
id: string
|
|
12
|
+
message: Record<string, string>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 通常操作用 Redis クライアントの最小面 (共有クライアント)。 */
|
|
16
|
+
export type DailyReportRedisClient = {
|
|
17
|
+
get(key: string): Promise<string | null>
|
|
18
|
+
incr(key: string): Promise<number>
|
|
19
|
+
xAdd(key: string, id: string, message: Record<string, string>, options?: unknown): Promise<string>
|
|
20
|
+
xRange(key: string, start: string, end: string, options?: { COUNT?: number }): Promise<RedisStreamMessage[]>
|
|
21
|
+
xRevRange(key: string, start: string, end: string, options?: { COUNT?: number }): Promise<RedisStreamMessage[]>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** ブロッキング xRead 用 Redis クライアントの最小面 (SSE リーダー専用接続)。 */
|
|
25
|
+
export type DailyReportRedisBlockingClient = {
|
|
26
|
+
isOpen: boolean
|
|
27
|
+
on(event: "error", listener: (err: Error) => void): unknown
|
|
28
|
+
xRead(streams: Array<{ key: string; id: string }>, options?: { BLOCK?: number; COUNT?: number }): Promise<Array<{ name: string; messages: RedisStreamMessage[] }> | null>
|
|
29
|
+
quit(): Promise<unknown>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Redis provider port: shared client for commands + dedicated client factory for blocking reads.
|
|
34
|
+
* Redis プロバイダーポート。コマンド用共有クライアントと、ブロッキング読み取り用専用クライアント生成を提供する。
|
|
35
|
+
*/
|
|
36
|
+
export type DailyReportRedisProvider = {
|
|
37
|
+
/** 共有クライアントを取得する (未接続・失敗時は undefined)。 */
|
|
38
|
+
getClient(): Promise<DailyReportRedisClient | undefined>
|
|
39
|
+
/** ブロッキング xRead 用の専用クライアントを生成する (失敗時は undefined)。 */
|
|
40
|
+
createClient(): Promise<DailyReportRedisBlockingClient | undefined>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 認証結果 (ユーザーは外部 ID を持つ最小形状)。 */
|
|
44
|
+
export type DailyReportAuthResult = {
|
|
45
|
+
user?: { id: string }
|
|
46
|
+
cookie?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Request authentication port (e.g. session/OpenID check in the host app).
|
|
51
|
+
* リクエスト認証ポート (ホストアプリのセッション / OpenID 検証)。
|
|
52
|
+
*
|
|
53
|
+
* `failureRedirect` が文字列の場合、未認証時は実装側で redirect を throw すること。
|
|
54
|
+
* null の場合は throw せず `user` 無しで解決する。
|
|
55
|
+
*/
|
|
56
|
+
export type DailyReportAuthenticate = (request: Request, options: { failureRedirect: string } | { failureRedirect: null }) => Promise<DailyReportAuthResult>
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolves an external user id (e.g. Google sub) to the internal numeric user id.
|
|
60
|
+
* 外部ユーザー ID (例: Google sub) を内部数値ユーザー ID へ解決するポート。
|
|
61
|
+
*/
|
|
62
|
+
export type DailyReportResolveUserId = (externalUserId: string) => Promise<number | null>
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Obfuscates an internal numeric user id for client exposure.
|
|
66
|
+
* 内部数値ユーザー ID をクライアント公開用に難読化するポート。
|
|
67
|
+
*/
|
|
68
|
+
export type DailyReportEncodeUserId = (id: number) => string
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON response helper with shared security headers and ETag support.
|
|
3
|
+
* 共有セキュリティヘッダーと ETag 対応を備えた JSON レスポンスヘルパー。
|
|
4
|
+
*/
|
|
5
|
+
import { createLogger, type DailyReportLogger, LogLevel } from "../shared/logger"
|
|
6
|
+
import { generateETag } from "./etag"
|
|
7
|
+
|
|
8
|
+
const defaultLogger = createLogger(LogLevel.INFO, "[Response]")
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Creates JSON response with shared security headers and ETag support.
|
|
12
|
+
* 共有のセキュリティヘッダーと ETag サポート付き JSON レスポンスを生成。
|
|
13
|
+
*/
|
|
14
|
+
export const jsonResponseWithETag = (request: Request, cookie: string | null, payload: Record<string, unknown>, status = 200, logger: DailyReportLogger = defaultLogger): Response => {
|
|
15
|
+
const etag = generateETag(payload)
|
|
16
|
+
const ifNoneMatch = request.headers.get("If-None-Match")
|
|
17
|
+
|
|
18
|
+
logger.info(`[jsonResponseWithETag] ETag: ${etag}, If-None-Match: ${ifNoneMatch}`)
|
|
19
|
+
|
|
20
|
+
const headers = new Headers({
|
|
21
|
+
"Content-Type": "application/json",
|
|
22
|
+
"Cache-Control": "private, max-age=0, must-revalidate",
|
|
23
|
+
"X-Content-Type-Options": "nosniff",
|
|
24
|
+
"X-Frame-Options": "DENY",
|
|
25
|
+
"Content-Security-Policy": "default-src 'none'",
|
|
26
|
+
ETag: etag,
|
|
27
|
+
})
|
|
28
|
+
if (cookie) {
|
|
29
|
+
headers.append("Set-Cookie", cookie)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (request.method === "GET" && status === 200 && ifNoneMatch === etag) {
|
|
33
|
+
return new Response(null, { status: 304, headers })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return new Response(JSON.stringify(payload), { status, headers })
|
|
37
|
+
}
|