@aiquants/daily-report 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/client.d.mts +81 -3
  2. package/dist/client.d.ts +81 -3
  3. package/dist/client.js +4 -4
  4. package/dist/client.js.map +1 -1
  5. package/dist/client.mjs +4 -4
  6. package/dist/client.mjs.map +1 -1
  7. package/dist/index.d.mts +2 -2
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.js +1 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.mjs +1 -1
  12. package/dist/index.mjs.map +1 -1
  13. package/dist/server.d.mts +434 -6
  14. package/dist/server.d.ts +434 -6
  15. package/dist/server.js +6 -6
  16. package/dist/server.js.map +1 -1
  17. package/dist/server.mjs +6 -6
  18. package/dist/server.mjs.map +1 -1
  19. package/dist/{sse-schema-rbG114od.d.mts → sse-schema-Df49KA7B.d.mts} +643 -249
  20. package/dist/{sse-schema-eXcMG-Ej.d.ts → sse-schema-RHckD7TS.d.ts} +643 -249
  21. package/dist/styles/daily-report.standalone.css +1 -1
  22. package/dist/{types-D1PKubyo.d.mts → types-Ct1ggzy-.d.mts} +30 -1
  23. package/dist/{types-D1PKubyo.d.ts → types-Ct1ggzy-.d.ts} +30 -1
  24. package/package.json +3 -3
  25. package/src/client/components/daily-report-attachment-indicator.spec.tsx +51 -0
  26. package/src/client/components/daily-report-attachment-indicator.tsx +45 -0
  27. package/src/client/components/daily-report-attachment-list.spec.tsx +94 -0
  28. package/src/client/components/daily-report-attachment-list.tsx +112 -0
  29. package/src/client/components/daily-report-detail-list.tsx +14 -7
  30. package/src/client/components/daily-report-list.tsx +19 -11
  31. package/src/client/components/report-views.spec.tsx +340 -0
  32. package/src/client/config-context.tsx +8 -0
  33. package/src/client/contexts/daily-report-action-context.tsx +2 -0
  34. package/src/client/hooks/use-responsive-layout.spec.ts +74 -0
  35. package/src/client/hooks/use-responsive-layout.ts +56 -0
  36. package/src/client/utils/constants.spec.ts +96 -0
  37. package/src/client/utils/constants.ts +47 -2
  38. package/src/client.ts +2 -0
  39. package/src/server/handlers.attachment.spec.ts +470 -0
  40. package/src/server/handlers.ts +426 -1
  41. package/src/server/ports.ts +74 -0
  42. package/src/server/schema.ts +80 -5
  43. package/src/server/service.spec.ts +280 -14
  44. package/src/server/service.ts +308 -25
  45. package/src/server/test-helpers/handlers-config.ts +142 -0
  46. package/src/server.ts +16 -1
  47. package/src/shared/sse-schema.spec.ts +50 -0
  48. package/src/shared/sse-schema.ts +27 -0
  49. package/src/shared/types.ts +31 -0
@@ -1,4 +1,5 @@
1
- import { bigint, mssqlSchema, nvarchar, primaryKey } from "drizzle-orm/mssql-core"
1
+ import type { SQL } from "drizzle-orm"
2
+ import { bigint, MsSqlDialect, mssqlSchema, nvarchar, primaryKey } from "drizzle-orm/mssql-core"
2
3
  import { describe, expect, it } from "vitest"
3
4
  import { createEpochStore, SqlResultCache } from "./cache"
4
5
  import { defineDailyReportSchema } from "./schema"
@@ -13,17 +14,92 @@ import { createDailyReportService, type DailyReportDb, type DailyReportHubRow }
13
14
  const testUsers = mssqlSchema("test").table("Users", { id: bigint("id", { mode: "number" }).notNull(), displayName: nvarchar("display_name", { length: 100 }) }, (t) => [primaryKey({ columns: [t.id] })])
14
15
  const tables = defineDailyReportSchema("test", { userTable: testUsers })
15
16
 
16
- /** getDailyReportsByBusinessDate 経路 (select→from→leftJoin→leftJoin→where→orderBy) だけを満たす最小モック。 */
17
- const makeDb = (rows: unknown[]): DailyReportDb => {
18
- const chain = {
19
- from: () => chain,
20
- innerJoin: () => chain,
21
- leftJoin: () => chain,
22
- where: () => chain,
23
- top: () => chain,
24
- orderBy: () => Promise.resolve(rows),
17
+ /** 本物の方言。`where` に渡された式を実際の SQL 文字列へ落として表明するために使う。 */
18
+ const dialect = new MsSqlDialect()
19
+
20
+ /** 1 本のクエリについてモックが記録した内容。 */
21
+ type RecordedQuery = {
22
+ /** `where` に渡された式を実 SQL へ落としたもの。未指定なら null。 */
23
+ whereSql: string | null
24
+ /**
25
+ * `where` の束縛値。
26
+ *
27
+ * SQL 文字列だけでは**認可の中身を検証できない**。drizzle は値をすべて `@parN` へ落とすため、
28
+ * 文字列は「どのカラムをどう結んだか」しか含まない。閲覧者 ID と添付 ID の取り違えは
29
+ * ここを見なければ 1 件も検出できない。
30
+ */
31
+ whereParams: unknown[]
32
+ /** `leftJoin` された回数。 */
33
+ leftJoins: number
34
+ /** `leftJoin` の ON 条件に載った束縛値 (下書きラベル ID はここに来る)。 */
35
+ joinParams: unknown[]
36
+ }
37
+
38
+ /** `update(...).set(...).where(...)` について記録した内容。 */
39
+ type RecordedUpdate = {
40
+ /** `set` に渡された更新内容。 */
41
+ set: Record<string, unknown>
42
+ /** `where` の束縛値。 */
43
+ whereParams: unknown[]
44
+ }
45
+
46
+ /**
47
+ * Minimal db mock that also records the SQL each query would emit.
48
+ * 各クエリが実際に生成する SQL を記録もする最小の db モック。
49
+ *
50
+ * チェーンは thenable にしてある。`.where()` で終わるクエリ (認可判定の
51
+ * `getAttachmentForUser` や `getDraftLabelId`) を `orderBy` 前提のモックで受けると
52
+ * チェーンオブジェクト自身が await の結果になり、分割代入が TypeError で落ちる。
53
+ * それを避けるためにモックを「どの段でも解決できる」形にしている。
54
+ *
55
+ * @param rows Rows every query resolves to. すべてのクエリが返す行。
56
+ * @returns The db mock and the per-query recordings. db モックとクエリごとの記録。
57
+ */
58
+ const makeDb = (rows: unknown[]): { db: DailyReportDb; queries: RecordedQuery[]; updates: RecordedUpdate[] } => {
59
+ const queries: RecordedQuery[] = []
60
+ const updates: RecordedUpdate[] = []
61
+ const select = () => {
62
+ const recorded: RecordedQuery = { whereSql: null, whereParams: [], leftJoins: 0, joinParams: [] }
63
+ queries.push(recorded)
64
+ const chain: Record<string, unknown> = {
65
+ from: () => chain,
66
+ innerJoin: () => chain,
67
+ leftJoin: (_table: unknown, onExpr?: SQL) => {
68
+ recorded.leftJoins += 1
69
+ if (onExpr) recorded.joinParams.push(...dialect.sqlToQuery(onExpr).params)
70
+ return chain
71
+ },
72
+ where: (expr: SQL | undefined) => {
73
+ const query = expr ? dialect.sqlToQuery(expr) : null
74
+ recorded.whereSql = query?.sql ?? null
75
+ recorded.whereParams = query?.params ?? []
76
+ return chain
77
+ },
78
+ top: () => chain,
79
+ orderBy: () => Promise.resolve(rows),
80
+ // `.where()` / `.orderBy()` のどちらで終わっても await できるようにする。
81
+ // thenable であること自体が目的なので、この規則はここでは意図的に外す
82
+ // biome-ignore lint/suspicious/noThenProperty: 意図的な thenable (クエリビルダの終端を模擬する)
83
+ then: (resolve: (value: unknown) => unknown) => resolve(rows),
84
+ }
85
+ return chain
86
+ }
87
+ const update = () => {
88
+ const recorded: RecordedUpdate = { set: {}, whereParams: [] }
89
+ const chain: Record<string, unknown> = {
90
+ set: (values: Record<string, unknown>) => {
91
+ recorded.set = values
92
+ return chain
93
+ },
94
+ where: (expr: SQL | undefined) => {
95
+ recorded.whereParams = expr ? dialect.sqlToQuery(expr).params : []
96
+ updates.push(recorded)
97
+ return Promise.resolve({ rowsAffected: [1] })
98
+ },
99
+ }
100
+ return chain
25
101
  }
26
- return { select: () => chain } as unknown as DailyReportDb
102
+ return { db: { select, update } as unknown as DailyReportDb, queries, updates }
27
103
  }
28
104
 
29
105
  const baseHub = (over: Partial<DailyReportHubRow>): DailyReportHubRow => ({
@@ -44,11 +120,12 @@ const baseHub = (over: Partial<DailyReportHubRow>): DailyReportHubRow => ({
44
120
  ...over,
45
121
  })
46
122
 
47
- const makeService = (rows: unknown[]) => {
123
+ const makeService = (rows: unknown[], withAttachmentCodec = false) => {
48
124
  const epochs = createEpochStore(undefined)
49
125
  const cache = new SqlResultCache({ defaultTtlMs: 60_000 }, epochs)
50
- return createDailyReportService({
51
- db: makeDb(rows),
126
+ const { db, queries, updates } = makeDb(rows)
127
+ const service = createDailyReportService({
128
+ db,
52
129
  tables,
53
130
  userTable: testUsers,
54
131
  resolveUserId: async () => 8,
@@ -56,7 +133,11 @@ const makeService = (rows: unknown[]) => {
56
133
  cache,
57
134
  epochs,
58
135
  draftLabelName: "draft", // 必須注入 (ドラフト可視性フィルタ用のラベル名。テストは中立値で十分)
136
+ // 既定では未注入のまま。既存テストが縮退動作 (添付は常に空) で通ることも同時に確認する。
137
+ // ユーザー ID 用 (`ENC`) とは別の接頭辞にして ID 空間の分離を表現する。
138
+ ...(withAttachmentCodec ? { attachmentIdCodec: { encode: (id: number) => `ATT${id}`, decode: (t: string) => (t.startsWith("ATT") ? Number(t.slice(3)) : null) } } : {}),
59
139
  })
140
+ return Object.assign(service, { __queries: queries, __updates: updates })
60
141
  }
61
142
 
62
143
  describe("mapHubRecord audit-actor masking (security)", () => {
@@ -95,3 +176,188 @@ describe("mapHubRecord audit-actor masking (security)", () => {
95
176
  expect(r.author).toBe("") // author は非 null 契約のため空文字
96
177
  })
97
178
  })
179
+
180
+ describe("添付ファイルの一括取得", () => {
181
+ it("コーデック未注入なら空マップを返す (既定の縮退動作)", async () => {
182
+ const svc = makeService([])
183
+ const map = await svc.getAttachmentsByHubIds([1, 2])
184
+ expect(map.size).toBe(0)
185
+ })
186
+
187
+ it("hubIds が空なら DB を叩かずに空マップを返す", async () => {
188
+ const svc = makeService([], true)
189
+ const map = await svc.getAttachmentsByHubIds([])
190
+ expect(map.size).toBe(0)
191
+ })
192
+
193
+ it("hub ID ごとにグルーピングし、内部数値 ID を難読化トークンへ変換する", async () => {
194
+ const rows = [
195
+ { id: 11, hubId: 1, fileName: "a.png", fileType: "image/png", fileSize: 10, createdAt: "2099-01-05 00:00:00", objectCheckedAt: null, objectMissingAt: null },
196
+ { id: 12, hubId: 1, fileName: "b.pdf", fileType: null, fileSize: null, createdAt: "2099-01-05 00:00:01", objectCheckedAt: "2099-01-06 00:00:00", objectMissingAt: null },
197
+ { id: 13, hubId: 2, fileName: "c.txt", fileType: "text/plain", fileSize: 0, createdAt: "2099-01-05 00:00:02", objectCheckedAt: "2099-01-06 00:00:00", objectMissingAt: "2099-01-06 00:00:00" },
198
+ ]
199
+ const svc = makeService(rows, true)
200
+ const map = await svc.getAttachmentsByHubIds([1, 2])
201
+
202
+ expect(map.get(1)).toHaveLength(2)
203
+ expect(map.get(2)).toHaveLength(1)
204
+ // 生の内部 ID が出ないこと。ユーザー ID 用の接頭辞とも異なること
205
+ expect(map.get(1)?.[0].id).toBe("ATT11")
206
+ expect(map.get(1)?.[0].id).not.toBe("11")
207
+ expect(map.get(1)?.[0].id.startsWith("ENC")).toBe(false)
208
+ })
209
+
210
+ it("実体状態を三値へ導出する (未検証 / 実在 / 消失)", async () => {
211
+ const rows = [
212
+ { id: 1, hubId: 1, fileName: "unknown.bin", fileType: null, fileSize: null, createdAt: null, objectCheckedAt: null, objectMissingAt: null },
213
+ { id: 2, hubId: 1, fileName: "present.bin", fileType: null, fileSize: null, createdAt: null, objectCheckedAt: "2099-01-06 00:00:00", objectMissingAt: null },
214
+ { id: 3, hubId: 1, fileName: "absent.bin", fileType: null, fileSize: null, createdAt: null, objectCheckedAt: "2099-01-06 00:00:00", objectMissingAt: "2099-01-06 00:00:00" },
215
+ ]
216
+ const svc = makeService(rows, true)
217
+ const list = (await svc.getAttachmentsByHubIds([1])).get(1) ?? []
218
+ expect(list.map((a) => a.state)).toEqual(["unknown", "present", "absent"])
219
+ })
220
+
221
+ it("サイズ 0 と サイズ不明 (null) を区別する", async () => {
222
+ const rows = [
223
+ { id: 1, hubId: 1, fileName: "empty.bin", fileType: null, fileSize: 0, createdAt: null, objectCheckedAt: null, objectMissingAt: null },
224
+ { id: 2, hubId: 1, fileName: "unsized.bin", fileType: null, fileSize: null, createdAt: null, objectCheckedAt: null, objectMissingAt: null },
225
+ ]
226
+ const svc = makeService(rows, true)
227
+ const list = (await svc.getAttachmentsByHubIds([1])).get(1) ?? []
228
+ expect(list[0].fileSize).toBe(0)
229
+ expect(list[1].fileSize).toBeNull()
230
+ })
231
+
232
+ it("空文字の fileType を null へ正規化する (取込元が欠損を空文字で表現する場合)", async () => {
233
+ const rows = [{ id: 1, hubId: 1, fileName: "x.bin", fileType: "", fileSize: null, createdAt: null, objectCheckedAt: null, objectMissingAt: null }]
234
+ const svc = makeService(rows, true)
235
+ expect((await svc.getAttachmentsByHubIds([1])).get(1)?.[0].fileType).toBeNull()
236
+ })
237
+ })
238
+
239
+ describe("添付ファイルの認可付き取得", () => {
240
+ it("添付テーブル未注入なら null を返す", async () => {
241
+ const epochs = createEpochStore(undefined)
242
+ const cache = new SqlResultCache({ defaultTtlMs: 60_000 }, epochs)
243
+ const { attachment: _omitted, ...tablesWithoutAttachment } = tables
244
+ const svc = createDailyReportService({
245
+ db: makeDb([]).db,
246
+ tables: tablesWithoutAttachment,
247
+ userTable: testUsers,
248
+ resolveUserId: async () => 8,
249
+ encodeUserId: (id) => `ENC${id}`,
250
+ cache,
251
+ epochs,
252
+ draftLabelName: "draft",
253
+ })
254
+ expect(await svc.getAttachmentForUser(1, 8)).toBeNull()
255
+ })
256
+
257
+ it("認可述語が閲覧者の所有と下書き非該当を OR で結び、論理削除も除外する", async () => {
258
+ // これがこの機能の唯一の認可境界。述語そのものを SQL 文字列として固定する
259
+ // (モックは行を絞れないので、生成される WHERE 句を直接表明するしかない)
260
+ const svc = makeService([{ fileName: "a.png", filePath: "gcs://b/a.png", fileType: "image/png", fileSize: 1 }])
261
+ await svc.getAttachmentForUser(11, 8)
262
+
263
+ // 最後のクエリが添付の認可判定 (直前は下書きラベルの解決)
264
+ const recorded = svc.__queries.at(-1)
265
+ const where = recorded?.whereSql ?? ""
266
+ expect(where).toContain("[DailyReportAttachment].[id]")
267
+ expect(where).toContain("[deleted_at] is null")
268
+ expect(where).toContain("[user_id] = ")
269
+ expect(where).toContain(" or ")
270
+ expect(where).toContain("[draft_label].[hub_id] is null")
271
+ // ❗ SQL 文字列だけでは足りない。値はすべて @parN へ落ちるため、
272
+ // 「添付 ID の位置に閲覧者 ID を渡す」ような取り違えは束縛値を見ないと検出できない
273
+ expect(recorded?.whereParams).toEqual([11, 8])
274
+ })
275
+
276
+ it("認可判定の束縛値が入れ替わっていないこと (添付 ID と閲覧者 ID)", async () => {
277
+ const svc = makeService([])
278
+ await svc.getAttachmentForUser(777, 42)
279
+ expect(svc.__queries.at(-1)?.whereParams).toEqual([777, 42])
280
+ })
281
+
282
+ it("営業日一覧・詳細取得・添付取得の 3 経路が同一の可視性述語を持つ", async () => {
283
+ // 1 経路だけ緩いと、緩い経路がメタデータ (件名・添付名・トークン) を渡してしまう。
284
+ // 「所有者本人」または「下書きラベルが付いていない」の OR を 3 経路すべてで固定する
285
+ const draftPredicate = (sql: string): boolean => /\[user_id\] = @par\d+\) or \(\(\[draft_label\]\.\[hub_id\] is null\)/.test(sql)
286
+
287
+ const byDate = makeService([])
288
+ await byDate.getDailyReportsByBusinessDateByExternalId("2099-01-05", "ext", { forceRefresh: true })
289
+ expect(byDate.__queries.some((q) => q.whereSql !== null && draftPredicate(q.whereSql))).toBe(true)
290
+
291
+ const byId = makeService([])
292
+ await byId.getDailyReportDetailById(1, 8, { forceRefresh: true })
293
+ expect(byId.__queries.some((q) => q.whereSql !== null && draftPredicate(q.whereSql))).toBe(true)
294
+
295
+ const byAttachment = makeService([])
296
+ await byAttachment.getAttachmentForUser(1, 8)
297
+ expect(byAttachment.__queries.some((q) => q.whereSql !== null && draftPredicate(q.whereSql))).toBe(true)
298
+ })
299
+
300
+ it("ID 一覧経路だけは `user_id IS NULL` を追加で許す (意図的な差分)", async () => {
301
+ // 4 番目の経路。取込元が投稿者を持たない日報 (user_id IS NULL) を一覧から消さないための
302
+ // 意図的な緩和であり、返すのは ID・営業日・ソース種別だけで本文も添付も含まない。
303
+ // 「3 経路と同一」ではないことを明示的に固定し、無自覚な差分と区別する
304
+ const byIds = makeService([])
305
+ await byIds.getDailyReportIdsByExternalId("ext", { forceRefresh: true })
306
+ const idsWhere = byIds.__queries.map((q) => q.whereSql ?? "").find((sql) => sql.includes("[draft_label].[hub_id] is null"))
307
+ expect(idsWhere).toBeDefined()
308
+ expect(idsWhere).toContain("[user_id] is null")
309
+ })
310
+
311
+ it("下書きラベルは複数解決し、可視性の結合が 2 つ目以降も対象にする", async () => {
312
+ // `draftLabelNames` には取込元ごとの下書き名を複数注入できる。1 個の ID でしか結合しないと
313
+ // 2 つ目の下書きラベルが付いた日報が誰にでも可視になる
314
+ const svc = makeService([{ id: 11 }, { id: 22 }])
315
+ await svc.getAttachmentForUser(1, 8)
316
+ // 結合条件は where ではなく leftJoin の ON 側に載る
317
+ const joinParams = svc.__queries.flatMap((q) => q.joinParams)
318
+ expect(joinParams).toContain(11)
319
+ expect(joinParams).toContain(22)
320
+ })
321
+ })
322
+
323
+ describe("実体存在の記録 (三値の書き込み側)", () => {
324
+ it("markAttachmentMissing は消失時刻と検証時刻の両方を立てる", async () => {
325
+ const svc = makeService([])
326
+ await svc.markAttachmentMissing(77)
327
+ const [update] = svc.__updates
328
+ expect(update?.whereParams).toEqual([77])
329
+ // objectMissingAt を落とすと三値が absent にならず、消失が永久に検知されない
330
+ expect(update?.set.objectMissingAt).toBeInstanceOf(Date)
331
+ expect(update?.set.objectCheckedAt).toBeInstanceOf(Date)
332
+ expect(update?.set.updatedAt).toBeInstanceOf(Date)
333
+ })
334
+
335
+ it("markAttachmentPresent は消失時刻を NULL へ戻す (自己回復)", async () => {
336
+ const svc = makeService([])
337
+ await svc.markAttachmentPresent(77)
338
+ const [update] = svc.__updates
339
+ expect(update?.whereParams).toEqual([77])
340
+ // ここが null でないと absent が解除されず、UI からリンクが戻らない
341
+ expect(update?.set.objectMissingAt).toBeNull()
342
+ expect(update?.set.objectCheckedAt).toBeInstanceOf(Date)
343
+ })
344
+
345
+ it("添付テーブル未注入なら書き込みを試みない", async () => {
346
+ const epochs = createEpochStore(undefined)
347
+ const { attachment: _omitted, ...tablesWithoutAttachment } = tables
348
+ const { db, updates } = makeDb([])
349
+ const svc = createDailyReportService({
350
+ db,
351
+ tables: tablesWithoutAttachment,
352
+ userTable: testUsers,
353
+ resolveUserId: async () => 8,
354
+ encodeUserId: (id) => `ENC${id}`,
355
+ cache: new SqlResultCache({ defaultTtlMs: 60_000 }, epochs),
356
+ epochs,
357
+ draftLabelName: "draft",
358
+ })
359
+ await svc.markAttachmentMissing(1)
360
+ await svc.markAttachmentPresent(1)
361
+ expect(updates).toHaveLength(0)
362
+ })
363
+ })