@aiquants/daily-report 0.6.2 → 0.8.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 (67) hide show
  1. package/README.md +13 -6
  2. package/dist/client.d.mts +443 -23
  3. package/dist/client.d.ts +443 -23
  4. package/dist/client.js +5 -4
  5. package/dist/client.js.map +1 -1
  6. package/dist/client.mjs +5 -4
  7. package/dist/client.mjs.map +1 -1
  8. package/dist/index.d.mts +77 -3
  9. package/dist/index.d.ts +77 -3
  10. package/dist/index.js +1 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +1 -1
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/server.d.mts +436 -6
  15. package/dist/server.d.ts +436 -6
  16. package/dist/server.js +8 -6
  17. package/dist/server.js.map +1 -1
  18. package/dist/server.mjs +8 -6
  19. package/dist/server.mjs.map +1 -1
  20. package/dist/{sse-schema-rbG114od.d.mts → sse-schema-Df49KA7B.d.mts} +643 -249
  21. package/dist/{sse-schema-eXcMG-Ej.d.ts → sse-schema-RHckD7TS.d.ts} +643 -249
  22. package/dist/styles/daily-report.standalone.css +1 -1
  23. package/dist/{types-D1PKubyo.d.mts → types-Ct1ggzy-.d.mts} +30 -1
  24. package/dist/{types-D1PKubyo.d.ts → types-Ct1ggzy-.d.ts} +30 -1
  25. package/package.json +3 -3
  26. package/src/client/components/daily-report-attachment-indicator.spec.tsx +51 -0
  27. package/src/client/components/daily-report-attachment-indicator.tsx +45 -0
  28. package/src/client/components/daily-report-attachment-list.spec.tsx +94 -0
  29. package/src/client/components/daily-report-attachment-list.tsx +112 -0
  30. package/src/client/components/daily-report-detail-list.tsx +14 -7
  31. package/src/client/components/daily-report-ids-stream-status.spec.tsx +93 -0
  32. package/src/client/components/daily-report-ids-stream-status.tsx +67 -0
  33. package/src/client/components/daily-report-list.tsx +19 -11
  34. package/src/client/components/daily-report-page.spec.tsx +119 -0
  35. package/src/client/components/daily-report-page.tsx +56 -68
  36. package/src/client/components/daily-report-resolved-content.tsx +31 -7
  37. package/src/client/components/report-views.spec.tsx +340 -0
  38. package/src/client/config-context.tsx +8 -0
  39. package/src/client/contexts/daily-report-action-context.spec.tsx +262 -0
  40. package/src/client/contexts/daily-report-action-context.tsx +151 -52
  41. package/src/client/hooks/use-animated-number.spec.ts +103 -0
  42. package/src/client/hooks/use-animated-number.ts +69 -0
  43. package/src/client/hooks/use-responsive-layout.spec.ts +74 -0
  44. package/src/client/hooks/use-responsive-layout.ts +56 -0
  45. package/src/client/route-helpers.spec.ts +100 -0
  46. package/src/client/route-helpers.ts +20 -41
  47. package/src/client/streaming/daily-report-ids-stream-client.spec.ts +641 -0
  48. package/src/client/streaming/daily-report-ids-stream-client.ts +599 -0
  49. package/src/client/streaming/daily-report-ids-stream-session.spec.ts +360 -0
  50. package/src/client/streaming/daily-report-ids-stream-session.ts +280 -0
  51. package/src/client/utils/constants.spec.ts +96 -0
  52. package/src/client/utils/constants.ts +47 -2
  53. package/src/client.ts +6 -0
  54. package/src/index.ts +1 -0
  55. package/src/server/handlers.attachment.spec.ts +470 -0
  56. package/src/server/handlers.ids-stream.spec.ts +362 -0
  57. package/src/server/handlers.ts +671 -6
  58. package/src/server/ports.ts +74 -0
  59. package/src/server/schema.ts +80 -5
  60. package/src/server/service.spec.ts +280 -14
  61. package/src/server/service.ts +348 -25
  62. package/src/server/test-helpers/handlers-config.ts +181 -0
  63. package/src/server.ts +16 -1
  64. package/src/shared/ids-stream.ts +92 -0
  65. package/src/shared/sse-schema.spec.ts +50 -0
  66. package/src/shared/sse-schema.ts +27 -0
  67. package/src/shared/types.ts +31 -0
@@ -66,3 +66,77 @@ export type DailyReportResolveUserId = (externalUserId: string) => Promise<numbe
66
66
  * 内部数値ユーザー ID をクライアント公開用に難読化するポート。
67
67
  */
68
68
  export type DailyReportEncodeUserId = (id: number) => string
69
+
70
+ /**
71
+ * Reversible obfuscation codec for an internal numeric id exposed to clients.
72
+ * クライアントへ公開する内部数値 ID の可逆難読化コーデック。
73
+ *
74
+ * これは難読化であって認可ではない。トークンを能力 (capability) として扱ってはならず、
75
+ * 認可は必ずサーバー側の述語で行うこと。ID 空間を混ぜないため、リソース種別ごとに
76
+ * 別インスタンス (別 alphabet 等) を注入することを推奨する。
77
+ */
78
+ export type DailyReportIdCodec = {
79
+ /** 内部数値 ID をトークンへ変換する。 */
80
+ encode: (id: number) => string
81
+ /** トークンを内部数値 ID へ戻す。復号できない場合は null。 */
82
+ decode: (token: string) => number | null
83
+ }
84
+
85
+ /**
86
+ * Failure kinds a caller maps onto HTTP statuses.
87
+ * 呼び出し側が HTTP ステータスへ写像する失敗種別。
88
+ *
89
+ * - `not_found`: 実体が存在しない (消失・未正規化 URI)
90
+ * - `denied`: ストレージ側で拒否された (サンドボックス外・ACL)
91
+ * - `too_large`: 上限超過 (事前サイズ判定またはワイヤ上限)
92
+ * - `invalid_path`: 受け入れないスキーム・不正なパス (データ不備)
93
+ * - `unavailable`: ストレージサービスへ到達できない・締め切り超過
94
+ */
95
+ export type DailyReportAttachmentFailure = "not_found" | "denied" | "too_large" | "invalid_path" | "unavailable"
96
+
97
+ /** 添付ファイル読み取りの成功結果。 */
98
+ export type DailyReportAttachmentBytes = {
99
+ ok: true
100
+ /**
101
+ * Byte view backed by an `ArrayBuffer`.
102
+ * `ArrayBuffer` を裏に持つバイトビュー。
103
+ *
104
+ * 素の `Uint8Array` と書くと `Uint8Array<ArrayBufferLike>` へ広がり `BodyInit` へ代入できない
105
+ * (TypeScript 5.7 以降の既定型引数)。`Response` のボディへそのまま渡すため narrow 型で受ける。
106
+ * `head` が true の呼び出しでは長さ 0 のビューを返してよい (実サイズは `size` で申告する)。
107
+ */
108
+ bytes: Uint8Array<ArrayBuffer>
109
+ /** バックエンドが申告した MIME 型。判定できない場合は null。 */
110
+ contentType?: string | null
111
+ /**
112
+ * Entity size in bytes, independent of how many bytes `bytes` actually holds.
113
+ * 実体のバイト数。`bytes` が実際に保持している長さとは独立に申告する値。
114
+ *
115
+ * HEAD 応答の `Content-Length` は同じ URI への GET が返す値と一致しなければならない
116
+ * (RFC 9110 §9.3.2)。`head` が true の呼び出しは本体を読まないため `bytes` は空になるので、
117
+ * ここで実サイズを申告しないと HEAD が常に `Content-Length: 0` を返してしまう。
118
+ * 申告できない場合は省略してよく、そのとき呼び出し側は `bytes.byteLength` を使う。
119
+ */
120
+ size?: number
121
+ }
122
+
123
+ /** 添付ファイル読み取りの失敗結果。 */
124
+ export type DailyReportAttachmentError = {
125
+ ok: false
126
+ reason: DailyReportAttachmentFailure
127
+ /** 診断ログ用の生のストレージエラーコード。HTTP ステータスの決定には使わない。 */
128
+ code?: string
129
+ }
130
+
131
+ /**
132
+ * Reads attachment bytes from the host application's storage backend.
133
+ * ホストアプリのストレージバックエンドから添付ファイルのバイト列を読み取るポート。
134
+ *
135
+ * `filePath` はサーバー内部にとどまり、クライアントへは決して渡らない。
136
+ * 失敗は種別付きで返すこと (例外を投げない・単一の null へ潰さない)。
137
+ * 種別を潰すと「実体が消えた」「上限超過」「サービス停止」が運用上区別できなくなる。
138
+ *
139
+ * `head` が true のときは実体を読まずメタデータのみ取得する。HEAD 要求で本体を読んで
140
+ * 捨てると、認証済み呼び出し元が任意に転送量とレイテンシを増幅できる経路になる。
141
+ */
142
+ export type DailyReportReadAttachment = (filePath: string, options: { maxBytes: number; head: boolean; principal?: string }) => Promise<DailyReportAttachmentBytes | DailyReportAttachmentError>
@@ -6,7 +6,7 @@
6
6
  * 新規プロジェクトは `defineDailyReportSchema()` で同一構造のテーブル定義を生成できる。
7
7
  */
8
8
  import { desc, sql } from "drizzle-orm"
9
- import { type AnyMsSqlColumn, bigint, bit, date, datetime2, foreignKey, index, int, mssqlSchema, nvarchar, primaryKey } from "drizzle-orm/mssql-core"
9
+ import { type AnyMsSqlColumn, bigint, bit, date, datetime2, foreignKey, index, int, mssqlSchema, nvarchar, primaryKey, uniqueIndex } from "drizzle-orm/mssql-core"
10
10
 
11
11
  /** 注入する外部ユーザーテーブルの最小形 (id / display_name)。 */
12
12
  export type DailyReportUserTable = { id: AnyMsSqlColumn; displayName: AnyMsSqlColumn }
@@ -85,19 +85,50 @@ export type DailyReportUserStatusTable = {
85
85
  updatedBy: AnyMsSqlColumn
86
86
  }
87
87
 
88
- /** サービスへ注入するテーブル一式 (アプリ既存モデルまたは本ファクトリ生成物)。 */
88
+ /**
89
+ * DailyReportAttachment テーブルの構造形。
90
+ *
91
+ * `uploadedBy` は外部取込ソース由来の行に投稿者が存在しないため NULL 許容。
92
+ * `sourceKey` は取込の冪等 upsert の自然キー。
93
+ * `objectCheckedAt` / `objectMissingAt` から実体存在の三値 (unknown / present / absent) を導出する。
94
+ */
95
+ export type DailyReportAttachmentTable = {
96
+ id: AnyMsSqlColumn
97
+ hubId: AnyMsSqlColumn
98
+ uploadedBy: AnyMsSqlColumn
99
+ sourceKey: AnyMsSqlColumn
100
+ fileName: AnyMsSqlColumn
101
+ filePath: AnyMsSqlColumn
102
+ fileType: AnyMsSqlColumn
103
+ fileSize: AnyMsSqlColumn
104
+ objectCheckedAt: AnyMsSqlColumn
105
+ objectMissingAt: AnyMsSqlColumn
106
+ createdAt: AnyMsSqlColumn
107
+ createdBy: AnyMsSqlColumn
108
+ updatedAt: AnyMsSqlColumn
109
+ updatedBy: AnyMsSqlColumn
110
+ }
111
+
112
+ /**
113
+ * サービスへ注入するテーブル一式 (アプリ既存モデルまたは本ファクトリ生成物)。
114
+ *
115
+ * `attachment` のみ任意。添付テーブルを持たない消費アプリでも本パッケージを使えるようにするため。
116
+ * 未注入時は詳細の `attachments` が常に空配列になり、添付エンドポイントは全トークンで 404 を返す
117
+ * (意図した縮退動作)。
118
+ */
89
119
  export type DailyReportTables = {
90
120
  hub: DailyReportHubTable
91
121
  internal: DailyReportInternalTable
92
122
  comment: DailyReportCommentTable
123
+ attachment?: DailyReportAttachmentTable
93
124
  label: DailyReportLabelTable
94
125
  hubLabel: DailyReportHubLabelTable
95
126
  userStatus: DailyReportUserStatusTable
96
127
  }
97
128
 
98
129
  /**
99
- * Generic factory: build the six daily-report tables under an arbitrary schema name.
100
- * 任意のスキーマ名の下に日報 6 テーブルを生成する汎用ファクトリ。
130
+ * Generic factory: build the seven daily-report tables under an arbitrary schema name.
131
+ * 任意のスキーマ名の下に日報 7 テーブルを生成する汎用ファクトリ。
101
132
  *
102
133
  * 制約・索引名は `${schemaName}_<Table>_...` 規約で生成する。
103
134
  * ユーザーテーブルへの FK は注入された `userTable` を参照する。
@@ -262,5 +293,49 @@ export function defineDailyReportSchema<S extends string>(schemaName: S, opts: {
262
293
  ],
263
294
  )
264
295
 
265
- return { hub, internal, comment, label, hubLabel, userStatus } satisfies DailyReportTables
296
+ const attachment = s.table(
297
+ "DailyReportAttachment",
298
+ {
299
+ id: bigint("id", { mode: "number" }).identity().notNull(),
300
+ hubId: bigint("hub_id", { mode: "number" }).notNull(),
301
+ // 外部取込ソース由来の行には投稿者が存在しないため NULL 許容
302
+ uploadedBy: bigint("uploaded_by", { mode: "number" }),
303
+ // 取込の冪等 upsert の自然キー。`<source>:<external-id>` 形式を推奨 (値の規約は消費アプリが決める)
304
+ sourceKey: nvarchar("source_key", { length: 200 }).notNull(),
305
+ fileName: nvarchar("file_name", { length: 255 }).notNull(),
306
+ // ストレージ URI。受け入れるのは `gcs://` のみ (取込側で表明・読み取り側で再検証)
307
+ filePath: nvarchar("file_path", { length: "max" }).notNull(),
308
+ fileType: nvarchar("file_type", { length: 100 }),
309
+ fileSize: bigint("file_size", { mode: "number" }),
310
+ // 実体存在の三値をこの 2 列から導出する:
311
+ // objectCheckedAt が null -> unknown (未検証)
312
+ // objectMissingAt が非 null -> absent (消失を確認)
313
+ // 上記以外 -> present (実在を確認)
314
+ objectCheckedAt: datetime2("object_checked_at"),
315
+ objectMissingAt: datetime2("object_missing_at"),
316
+ createdAt: datetime2("created_at").notNull(),
317
+ createdBy: nvarchar("created_by", { length: 50 }).notNull(),
318
+ updatedAt: datetime2("updated_at").notNull(),
319
+ updatedBy: nvarchar("updated_by", { length: 50 }).notNull(),
320
+ },
321
+ (table) => [
322
+ primaryKey({ name: `${schemaName}_DailyReportAttachment_pk`, columns: [table.id] }),
323
+ foreignKey({
324
+ name: `${schemaName}_DailyReportAttachment_hub_id_fk`,
325
+ columns: [table.hubId],
326
+ foreignColumns: [hub.id],
327
+ }),
328
+ foreignKey({
329
+ name: `${schemaName}_DailyReportAttachment_uploaded_by_fk`,
330
+ columns: [table.uploadedBy],
331
+ foreignColumns: [users.id],
332
+ }),
333
+ // 取込の冪等性を DB 側で担保する
334
+ uniqueIndex(`${schemaName}_DailyReportAttachment_source_unique`).on(table.hubId, table.sourceKey),
335
+ // 一括取得 (hub_id IN (...) ORDER BY created_at) を被覆する
336
+ index(`${schemaName}_DailyReportAttachment_hub_id_index`).on(table.hubId, table.createdAt),
337
+ ],
338
+ )
339
+
340
+ return { hub, internal, comment, attachment, label, hubLabel, userStatus } satisfies DailyReportTables
266
341
  }
@@ -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
+ })