@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.
- package/README.md +13 -6
- package/dist/client.d.mts +443 -23
- package/dist/client.d.ts +443 -23
- package/dist/client.js +5 -4
- package/dist/client.js.map +1 -1
- package/dist/client.mjs +5 -4
- package/dist/client.mjs.map +1 -1
- package/dist/index.d.mts +77 -3
- package/dist/index.d.ts +77 -3
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/server.d.mts +436 -6
- package/dist/server.d.ts +436 -6
- package/dist/server.js +8 -6
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +8 -6
- package/dist/server.mjs.map +1 -1
- package/dist/{sse-schema-rbG114od.d.mts → sse-schema-Df49KA7B.d.mts} +643 -249
- package/dist/{sse-schema-eXcMG-Ej.d.ts → sse-schema-RHckD7TS.d.ts} +643 -249
- package/dist/styles/daily-report.standalone.css +1 -1
- package/dist/{types-D1PKubyo.d.mts → types-Ct1ggzy-.d.mts} +30 -1
- package/dist/{types-D1PKubyo.d.ts → types-Ct1ggzy-.d.ts} +30 -1
- package/package.json +3 -3
- package/src/client/components/daily-report-attachment-indicator.spec.tsx +51 -0
- package/src/client/components/daily-report-attachment-indicator.tsx +45 -0
- package/src/client/components/daily-report-attachment-list.spec.tsx +94 -0
- package/src/client/components/daily-report-attachment-list.tsx +112 -0
- package/src/client/components/daily-report-detail-list.tsx +14 -7
- package/src/client/components/daily-report-ids-stream-status.spec.tsx +93 -0
- package/src/client/components/daily-report-ids-stream-status.tsx +67 -0
- package/src/client/components/daily-report-list.tsx +19 -11
- package/src/client/components/daily-report-page.spec.tsx +119 -0
- package/src/client/components/daily-report-page.tsx +56 -68
- package/src/client/components/daily-report-resolved-content.tsx +31 -7
- package/src/client/components/report-views.spec.tsx +340 -0
- package/src/client/config-context.tsx +8 -0
- package/src/client/contexts/daily-report-action-context.spec.tsx +262 -0
- package/src/client/contexts/daily-report-action-context.tsx +151 -52
- package/src/client/hooks/use-animated-number.spec.ts +103 -0
- package/src/client/hooks/use-animated-number.ts +69 -0
- package/src/client/hooks/use-responsive-layout.spec.ts +74 -0
- package/src/client/hooks/use-responsive-layout.ts +56 -0
- package/src/client/route-helpers.spec.ts +100 -0
- package/src/client/route-helpers.ts +20 -41
- package/src/client/streaming/daily-report-ids-stream-client.spec.ts +641 -0
- package/src/client/streaming/daily-report-ids-stream-client.ts +599 -0
- package/src/client/streaming/daily-report-ids-stream-session.spec.ts +360 -0
- package/src/client/streaming/daily-report-ids-stream-session.ts +280 -0
- package/src/client/utils/constants.spec.ts +96 -0
- package/src/client/utils/constants.ts +47 -2
- package/src/client.ts +6 -0
- package/src/index.ts +1 -0
- package/src/server/handlers.attachment.spec.ts +470 -0
- package/src/server/handlers.ids-stream.spec.ts +362 -0
- package/src/server/handlers.ts +671 -6
- package/src/server/ports.ts +74 -0
- package/src/server/schema.ts +80 -5
- package/src/server/service.spec.ts +280 -14
- package/src/server/service.ts +348 -25
- package/src/server/test-helpers/handlers-config.ts +181 -0
- package/src/server.ts +16 -1
- package/src/shared/ids-stream.ts +92 -0
- package/src/shared/sse-schema.spec.ts +50 -0
- package/src/shared/sse-schema.ts +27 -0
- package/src/shared/types.ts +31 -0
package/src/server/ports.ts
CHANGED
|
@@ -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>
|
package/src/server/schema.ts
CHANGED
|
@@ -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
|
|
100
|
-
* 任意のスキーマ名の下に日報
|
|
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
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 {
|
|
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
|
-
|
|
51
|
-
|
|
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
|
+
})
|