@vtecx/vtecxdocument 1.0.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.
@@ -0,0 +1,882 @@
1
+ # vte.cx フレームワーク仕様
2
+
3
+ vte.cx BaaS のデータモデル・アクセス制御・スキーマ設計に関するリファレンスです。
4
+ このドキュメントは vte.cx を使用するすべてのプロジェクトで横展開できます。
5
+
6
+ ---
7
+
8
+ ## データ構造
9
+
10
+ ### URI 設計
11
+
12
+ `getFeed` は指定したパス以下の**エントリ一覧を返す**ため、1つのディレクトリに複数種類のデータを混在させてはならない。
13
+
14
+ #### NG パターン(データ混在)
15
+
16
+ ```
17
+ /crm/customer/0000000001
18
+ /crm/customer/contact/... ← 担当者が顧客と同じディレクトリ
19
+ /crm/customer/deal/... ← 商談が顧客と同じディレクトリ
20
+ ```
21
+
22
+ `getFeed('/crm/customer')` を呼ぶと顧客一覧だけでなく担当者・商談も返ってくる。
23
+
24
+ #### OK パターン(種類ごとに独立したディレクトリ)
25
+
26
+ ```
27
+ /crm/customer/0000000001
28
+ /crm/customer/0000000001/contact ← 顧客Aの担当者専用
29
+ /crm/customer/0000000001/deal ← 顧客Aの商談専用
30
+ /crm/customer/0000000001/activity ← 顧客Aの対応履歴専用
31
+ ```
32
+
33
+ | クエリ | 返るデータ |
34
+ | --------------------------------------- | -------------------- |
35
+ | `getFeed('/crm/customer')` | 顧客一覧 |
36
+ | `getEntry('/crm/customer/{id}')` | 顧客データ(1件) |
37
+ | `getFeed('/crm/customer/{id}/contact')` | その顧客の担当者のみ |
38
+ | `getFeed('/crm/customer/{id}/deal')` | その顧客の商談のみ |
39
+
40
+ #### 設計ルール
41
+
42
+ 1. **1ディレクトリ = 1エンティティ種別** — 異なるエンティティを同じディレクトリ配下に置かない
43
+ 2. **親子関係はパス階層で表現** — 子エンティティのパスに親IDを含める(例: `/crm/contact/{customerId}/{cid}`)
44
+ 3. **folderacls はエンティティ種別ごとに登録** — データを置くルートパスをすべて登録する
45
+
46
+ ---
47
+
48
+ ### 戻り値の型
49
+
50
+ SDK メソッドごとに戻り値の型が異なる。`feed.entry` は SDK 内部で解決されるため、呼び出し側は意識しなくてよい。
51
+
52
+ | メソッド | 正常時の型 | 204(データなし) |
53
+ | ---------- | ------------------ | -------------------- |
54
+ | `getFeed` | `VtecxApp.Entry[]` | `null` / `undefined` |
55
+ | `getEntry` | `VtecxApp.Entry` | `null` / `undefined` |
56
+
57
+ エラー時は `{ feed: { title: string } }` 型が返る。
58
+
59
+ ```typescript
60
+ const entries = await vtecxnext.getFeed('/crm/customer')
61
+ // entries は VtecxApp.Entry[](配列)。Array.isArray による正規化は不要
62
+
63
+ const entry = await vtecxnext.getEntry('/crm/customer/0000000001')
64
+ // entry は VtecxApp.Entry(単一オブジェクト)または null(204)
65
+ ```
66
+
67
+ ---
68
+
69
+ ### entry.id の形式
70
+
71
+ `entry.id` は `{パス},{リビジョン}` の形式で返る。パスを取り出す場合は `,` より前の部分のみ使う。
72
+
73
+ ```typescript
74
+ // NG: リビジョン番号が混入する
75
+ const id = entry.id.replace('/crm/customer/', '') // → "0000000001,3"
76
+
77
+ // OK: パス部分のみ取り出す
78
+ const id = entry.id.split(',')[0].replace('/crm/customer/', '') // → "0000000001"
79
+ ```
80
+
81
+ ---
82
+
83
+ ## データ取得
84
+
85
+ ### 使い分け
86
+
87
+ | 状況 | 使うメソッド |
88
+ | --------------------------------------- | ------------------------------- |
89
+ | 件数が少なく全件取得で問題ない一覧 | `getFeed(uri)` |
90
+ | 件数が多くページング必要な一覧 | `getPageWithPagination(uri, n)` |
91
+ | 一覧の中から1件を取得(詳細・編集画面) | `getEntry(uri)` |
92
+
93
+ **基本ルール:**
94
+
95
+ - 一覧データの取得はページングを前提に行う(`getPageWithPagination`)
96
+ - 一覧からデータを1件選んで取得する場合は、必ずデータキー(URI)を使って `getEntry` で取得する
97
+ - ページング不要な少量データ(マスタ・サブリストなど)は `getFeed` でよい
98
+
99
+ ```typescript
100
+ // ページング一覧(顧客一覧など)
101
+ const entries = await vtecxnext.getPageWithPagination('/crm/customer?l=25', n)
102
+
103
+ // ページング不要一覧(件数が少ないサブリストなど)
104
+ const feed = await vtecxnext.getFeed('/crm/contact/0000000001')
105
+
106
+ // 1件取得(一覧→詳細遷移時、編集画面など)
107
+ const entry = await vtecxnext.getEntry('/crm/customer/0000000001')
108
+ ```
109
+
110
+ ---
111
+
112
+ ### メソッド一覧
113
+
114
+ | メソッド | 用途 |
115
+ | ------------------------------- | --------------------------------------------------------- |
116
+ | `getFeed(uri)` | ページング不要な一覧取得 |
117
+ | `getEntry(uri)` | データキーを指定して1件取得 |
118
+ | `getPageWithPagination(uri, n)` | ページング一覧取得(`pagination` + `getPage` を一括処理) |
119
+ | `pagination(uri, pagerange)` | カーソルリスト(pageindex)を作成 |
120
+ | `getPage(uri, n)` | 指定ページのデータを取得(カーソルリストが必要) |
121
+
122
+ ---
123
+
124
+ ### ページング
125
+
126
+ vte.cx のページングは **カーソルリスト(pageindex)** を事前に作成してから、ページ単位でデータを取得する2ステップ方式を取る。
127
+
128
+ ```
129
+ ① pagination() ─ 取得キー(URI)とページ範囲を指定してカーソルリストを作成
130
+ ② getPage() ─ 作成済みカーソルを使って指定ページのデータを取得
131
+ ```
132
+
133
+ `getPageWithPagination(uri, n)` はこの2ステップを内部で自動処理する。n=1 のとき自動的に `pagination()` を呼び出す。データが存在しない場合は `undefined` を返す。
134
+
135
+ #### URI パラメータ
136
+
137
+ | パラメータ | 指定箇所 | 説明 |
138
+ | ----------------------- | ------------------------- | ----------------------------- |
139
+ | `l=件数` | URI クエリ | 1ページ当たりの表示件数 |
140
+ | `n=ページ番号` | `getPage()` が自動付加 | 取得するページ番号(1始まり) |
141
+ | `_pagination=start,end` | `pagination()` が自動付加 | カーソルを作成するページ範囲 |
142
+
143
+ > `pagination()` と `getPage()` に渡す URI は完全一致である必要がある(検索条件も含めて同一にすること)。
144
+
145
+ #### pagerange
146
+
147
+ `pagination(uri, pagerange)` の `pagerange` は `"開始ページ,終了ページ"` 形式。**endPage は 50 推奨**。
148
+
149
+ ```
150
+ "1,50" ← ページ1〜50 のカーソルを一括作成(推奨)
151
+ "51,100" ← ページ51〜100 を追加作成(続きがある場合)
152
+ ```
153
+
154
+ #### 実装例
155
+
156
+ ```typescript
157
+ const n = parseInt(vtecxnext.getParameter('n') ?? '1', 10)
158
+ const uri = `/crm/customer?l=25`
159
+
160
+ const entries = await vtecxnext.getPageWithPagination(uri, n)
161
+ return vtecxnext.response(200, entries ?? null)
162
+ ```
163
+
164
+ #### PaginationInfo
165
+
166
+ `pagination()` の戻り値。
167
+
168
+ ```typescript
169
+ type PaginationInfo = {
170
+ lastPageNumber: number // 作成済みカーソルの最終ページ番号(0 = データなし)
171
+ countWithinRange: number // 指定ページ範囲内のエントリ数
172
+ hasNext: boolean // true = 指定 endPage を超えるデータが存在する
173
+ isMemorysort: boolean // メモリソートモードかどうか
174
+ }
175
+ ```
176
+
177
+ | 値 | 意味 | 対処 |
178
+ | ---------------------- | ---------------------------- | ----------------------------------------------- |
179
+ | `lastPageNumber === 0` | データが1件もない | null または空を返す |
180
+ | `hasNext === true` | endPage 以降にもデータがある | 必要なら `pagination(uri, '51,100')` を追加実行 |
181
+
182
+ ---
183
+
184
+ ## アクセス制御
185
+
186
+ ### 概念
187
+
188
+ vte.cx のアクセス制御はすべて **フレームワークが担う**。API ルートはアクセス制御を実装しない。
189
+
190
+ ```
191
+ ① クライアント(APIルート)がデータにアクセス権限を付与する
192
+ ② フレームワークがそのアクセス権限を参照してアクセス制御を行う
193
+ ③ アクセス権限がない場合はフレームワークが HTTP 403 を返す
194
+ ```
195
+
196
+ **API ルートではロールチェック・オーナーチェックを実装しない。** folderacls とエントリの contributor 設定がアクセス制御のすべてである。
197
+
198
+ ---
199
+
200
+ ### folderacls.json
201
+
202
+ アプリが使用するデータパスは、事前に vte.cx サーバーに登録する必要がある。
203
+
204
+ ```json
205
+ [
206
+ {
207
+ "contributor": [
208
+ { "uri": "urn:vte.cx:acl:/_group/$admin,CURD" },
209
+ { "uri": "urn:vte.cx:acl:+,CURDE" }
210
+ ],
211
+ "link": [{ "___rel": "self", "___href": "/your/path" }]
212
+ }
213
+ ]
214
+ ```
215
+
216
+ #### 主体と権限
217
+
218
+ | 主体 | 表記 | 用途 |
219
+ | ---------------- | ---------------- | ----------------------------------------- |
220
+ | システム管理者 | `/_group/$admin` | サービス作成者。必ず `CURD` を付与する |
221
+ | ログインユーザー | `+` | 認証済みユーザー全員。通常 `CURDE` を付与 |
222
+ | 全員(匿名含む) | `*` | 公開データ(`R` のみ推奨) |
223
+ | カスタムグループ | `/_group/{name}` | 独自ロール(例: `/_group/sales`) |
224
+
225
+ | 権限 | 意味 |
226
+ | ---- | ------------------------------------ |
227
+ | `C` | 作成(POST) |
228
+ | `R` | 読取(GET) |
229
+ | `U` | 更新(PUT) |
230
+ | `D` | 削除(DELETE) |
231
+ | `E` | API 経由のみ許可(直接アクセス禁止) |
232
+
233
+ - `/_group/$admin` には必ず `CURD` を付与する(`E` を付けると直接アクセスが制限される)
234
+ - ログインユーザー(`+`)には `CURDE` を付与し、API 経由のみに制限する
235
+ - 子パスを登録する場合、親パスも必ず登録する
236
+
237
+ #### システムディレクトリ
238
+
239
+ 先頭が `_` のパス(`/_group`、`/_html` など)はシステムディレクトリで、サービス作成時にフレームワークが自動生成する。folderacls.json への定義は不要。
240
+
241
+ ---
242
+
243
+ ### contributor によるアクセス権限付与
244
+
245
+ エントリへのアクセス権限は `contributor` フィールドで設定する。フォーマットは folderacls.json と同一。
246
+
247
+ ```
248
+ urn:vte.cx:acl:{グループキーまたはUID},{権限}
249
+ ```
250
+
251
+ ```typescript
252
+ const uid = await vtecxnext.uid()
253
+ const entry = {
254
+ link: [{ ___rel: 'self', ___href: '/crm/customer/0000000001' }],
255
+ customer: { ... },
256
+ contributor: [
257
+ { uri: 'urn:vte.cx:acl:/_group/$admin,CURD' },
258
+ { uri: `urn:vte.cx:acl:${uid},CURD` },
259
+ { uri: 'urn:vte.cx:acl:/_group/viewer,R' },
260
+ ],
261
+ }
262
+ ```
263
+
264
+ エントリを更新する際は `existing.contributor` を引き継ぐことで、元のアクセス権限が失われない。
265
+
266
+ ```typescript
267
+ const existing = await vtecxnext.getEntry(uri)
268
+ const entry = {
269
+ link: [{ ___rel: 'self', ___href: uri }],
270
+ id: existing?.id,
271
+ myData: { ... },
272
+ contributor: existing?.contributor,
273
+ }
274
+ await vtecxnext.put({ feed: { entry: [entry] } })
275
+ ```
276
+
277
+ ---
278
+
279
+ ### 403 レスポンスへの対応
280
+
281
+ フレームワークが 403 を返した場合、クライアントはセッション切れまたは権限不足として処理する。
282
+
283
+ ```typescript
284
+ if (error?.status === 403) {
285
+ router.push('/login')
286
+ }
287
+ ```
288
+
289
+ ---
290
+
291
+ ## スキーマ(template.xml)
292
+
293
+ ### 構文
294
+
295
+ エンティティのフィールドは `<content>` 内に DSL で定義する。
296
+
297
+ ```
298
+ エンティティ名
299
+ フィールド名(型){最大値}
300
+ フィールド名(型)!
301
+ ```
302
+
303
+ - 1スペースインデントで子フィールド(ネスト)
304
+ - `!` で必須フィールド、`{n}` で string の最大文字数・数値の最大値
305
+
306
+ #### 型
307
+
308
+ | 型 | 用途 |
309
+ | ------------------ | ---------------------- |
310
+ | `string` | 文字列 |
311
+ | `int` | 整数 |
312
+ | `long` | 大きな整数(金額など) |
313
+ | `date` | 日付(ISO 8601) |
314
+ | `boolean` | 真偽値 |
315
+ | `float` / `double` | 小数 |
316
+
317
+ ### スキーマ変更の制約
318
+
319
+ - **フィールドの削除は不可** — 一度定義したフィールドは恒久的に残る
320
+ - **追加フィールドは必ず末尾に追記する** — 途中に挿入するとスキーマが壊れる
321
+ - **`entry.title`, `entry.subtitle` は使用しない** — 全フィールドを専用スキーマで定義する
322
+ - **フィールド名は `snake_case`** — 小文字英字・数字・アンダースコアのみ(先頭は小文字英字)
323
+
324
+ ```
325
+ // ❌ NG: 既存フィールドの途中に挿入
326
+ activity
327
+ next_action(string){500}
328
+ next_action_date(date) ← 途中に挿入
329
+ contact_uri(string){500} ← 既存フィールド
330
+
331
+ // ✅ OK: 既存フィールドをすべて保持し、末尾に追記
332
+ activity
333
+ next_action(string){500}
334
+ contact_uri(string){500} ← 既存フィールドはそのまま
335
+ next_action_date(date) ← 末尾に追加
336
+ ```
337
+
338
+ ### template.xml ファイル全体の構造
339
+
340
+ ```xml
341
+ <?xml version="1.0" encoding="UTF-8" ?>
342
+ <feed>
343
+ <entry>
344
+ <content>customer
345
+ name(string){255}
346
+ address(string){500}
347
+ phone(string){20}
348
+ status(string){20}
349
+ deal
350
+ name(string){255}
351
+ amount(long)
352
+ probability(int){100}
353
+ scheduled_date(date)
354
+ </content>
355
+ <link href="/_settings/template" rel="self"></link>
356
+ <rights>
357
+ customer.status:/crm/customer
358
+ customer.assigned_uid:/crm/customer
359
+ deal.stage:/crm/deal
360
+ name;/crm/customer|/crm/deal
361
+ password#
362
+ </rights>
363
+ </entry>
364
+ <entry>
365
+ <link href="/_settings/template_property" rel="self"/>
366
+ </entry>
367
+ </feed>
368
+ ```
369
+
370
+ ---
371
+
372
+ ### インデックス設定(`<rights>` タグ)
373
+
374
+ フィールドに検索インデックスを設定することで、そのフィールドを使った絞り込み・ソートが可能になる。
375
+
376
+ #### 構文
377
+
378
+ ```
379
+ {フィールドパス}:{フォルダパスの正規表現}
380
+ ```
381
+
382
+ | 構文 | 種類 | 用途 |
383
+ | --------------------- | ---------------------- | -------------------------------- |
384
+ | `field:path` | 通常インデックス | 完全一致検索・ソート・範囲検索 |
385
+ | `field;path` | 全文検索インデックス | 部分一致検索(`like` 検索) |
386
+ | `field1\|field2;path` | 複数フィールド全文検索 | 複数フィールドをまとめて全文検索 |
387
+ | `field:path=role` | フィールド ACL | 指定ロールのみ読み書き可 |
388
+ | `field#` | 暗号化 | フィールド値をサーバー側で暗号化 |
389
+
390
+ #### 注意事項
391
+
392
+ - **同一フィールドへの複数パス設定は `|` で1行にまとめる**(別々の行に分けると "Already specified" エラー)
393
+ - インデックス追加後は必ず `pnpm upload:template` でサーバーに反映する
394
+ - **⚠️ インデックス設定前に登録済みのデータにはインデックスが適用されない** — 既存データを再 PUT する必要がある
395
+ - フォルダパスは正規表現のため `/crm/customer` は `/crm/customer` で始まるすべてのパスにマッチする
396
+
397
+ ---
398
+
399
+ ### インデックスを使った検索クエリ
400
+
401
+ ```
402
+ /path?f&entity.field-eq-value&l=25
403
+ ```
404
+
405
+ `f` パラメータ必須。インデックス検索が有効なのは **先頭の検索条件パラメータのみ**(2番目以降はメモリフィルタ)。
406
+
407
+ | 演算子 | 意味 |
408
+ | ------ | ------------------------- |
409
+ | `-eq-` | 等しい(完全一致) |
410
+ | `-ne-` | 等しくない |
411
+ | `-lt-` | より小さい |
412
+ | `-le-` | 以下 |
413
+ | `-gt-` | より大きい |
414
+ | `-ge-` | 以上 |
415
+ | `-fm-` | 前方一致(LIKE 'value%') |
416
+ | `-bm-` | 後方一致(LIKE '%value') |
417
+ | `-rg-` | 正規表現 |
418
+ | `-ft-` | 全文検索 |
419
+
420
+ OR 条件: `?f&|field-eq-a&|field-eq-b`
421
+
422
+ ---
423
+
424
+ ## ユーザーと UID
425
+
426
+ ### UID の発行
427
+
428
+ ユーザーがサービスに登録(サインアップ)すると、vte.cx フレームワークが自動的に **UID** を発行する。同時にユーザー専用ディレクトリ `/_user/{uid}` が生成される(folderacls.json への定義は不要)。
429
+
430
+ ### UID の取得
431
+
432
+ ```typescript
433
+ const vtecxnext = new VtecxNext(req)
434
+ const uid = await vtecxnext.uid()
435
+ ```
436
+
437
+ ### ログイン中のユーザー情報の取得
438
+
439
+ ログイン中ユーザーの詳細情報は `/_user/{uid}` エントリから取得する。
440
+
441
+ ```typescript
442
+ const uid = await vtecxnext.uid()
443
+ const userEntry = await vtecxnext.getEntry(`/_user/${uid}`)
444
+ const email: string = userEntry?.contributor?.[0]?.email ?? ''
445
+ ```
446
+
447
+ #### ユーザーエントリの構造(`/_user/{uid}`)
448
+
449
+ | フィールド | 内容 |
450
+ | --- | --- |
451
+ | `title` | UID |
452
+ | `subtitle` | ニックネーム |
453
+ | `summary` | ステータス(`Activated` / `Interim`) |
454
+ | `contributor[0].email` | メールアドレス |
455
+
456
+ ### アカウントの状態
457
+
458
+ | 状態 | summary 値 | ログイン |
459
+ | --- | --- | --- |
460
+ | 仮登録 | `Interim` | 不可(確認メール未クリック) |
461
+ | 本登録 | `Activated` | 可 |
462
+
463
+ ### サービス名の取得
464
+
465
+ ```typescript
466
+ const serviceName = await vtecxnext.service()
467
+ ```
468
+
469
+ ### ユーザー専用データの保存
470
+
471
+ ```typescript
472
+ const uid = await vtecxnext.uid()
473
+ const entry = {
474
+ link: [{ ___rel: 'self', ___href: `/crm/user/${uid}` }],
475
+ user: { displayName: 'John Doe', role: 'sales' },
476
+ }
477
+ await vtecxnext.put({ feed: { entry: [entry] } })
478
+ ```
479
+
480
+ ---
481
+
482
+ ## パスワード変更
483
+
484
+ vtecxnext のパスワード変更には **2 つのフロー** がある。いずれも内部で `vtecxnext.changepass()` を呼び出し、vte.cx の `PUT /_changephash` エンドポイントに送信する。
485
+
486
+ ### SDK メソッド
487
+
488
+ ```typescript
489
+ vtecxnext.changepass(newpswd: string, oldpswd?: string, passresetToken?: string)
490
+ ```
491
+
492
+ | 引数 | 説明 |
493
+ | --- | --- |
494
+ | `newpswd` | 新しいパスワード(ハッシュ済み) |
495
+ | `oldpswd` | 現在のパスワード(ハッシュ済み)。ログイン済みフローで使用 |
496
+ | `passresetToken` | パスワードリセットトークン。メールリセットフローで使用 |
497
+
498
+ パスワードは送信前に `getHashpass(password)`(`@vtecx/vtecxauth`)でハッシュする。
499
+
500
+ ### フロー 1:メールリセット(未ログイン)
501
+
502
+ ```
503
+ /forgot-password でメールアドレスを入力
504
+ → vtecxnext.passreset() でリセットメール送信
505
+ → メール内リンク(_passreset_token & _RXID 付き URL)をクリック
506
+ → /change-password ページで新パスワードを入力
507
+ → POST /api/changepass?_RXID={rxid} に { newpswd, passresetToken } を送信
508
+ → vtecxnext.loginWithRxid(rxid) でセッション確立後
509
+ → vtecxnext.changepass(newpswd, undefined, passresetToken) を実行
510
+ ```
511
+
512
+ ### フロー 2:現在のパスワードで変更(ログイン済み)
513
+
514
+ ```
515
+ /account/change-password を開く
516
+ → 現在のパスワード・新しいパスワードを入力
517
+ → POST /api/changepass に { newpswd, oldpswd } を送信(_RXID なし)
518
+ → セッションクッキーで認証済みのまま
519
+ → vtecxnext.changepass(newpswd, oldpswd) を実行
520
+ ```
521
+
522
+ ### API ルートの振り分けロジック
523
+
524
+ ```typescript
525
+ const rxid = vtecxnext.getParameter('_RXID') ?? ''
526
+ if (rxid) {
527
+ await vtecxnext.loginWithRxid(rxid)
528
+ } else if (!data.oldpswd) {
529
+ return vtecxnext.response(401, { feed: { title: 'Authentication error.' } })
530
+ }
531
+ await vtecxnext.changepass(data.newpswd, data.oldpswd, data.passresetToken)
532
+ ```
533
+
534
+ ---
535
+
536
+ ## グループ管理
537
+
538
+ ### グループディレクトリの事前登録
539
+
540
+ グループ(`/_group/admin` など)は folderacls.json に定義し、`pnpm upload:folderacls` でサーバーに登録する。
541
+
542
+ ```json
543
+ {
544
+ "contributor": [
545
+ { "uri": "urn:vte.cx:acl:/_group/$admin,CURD" },
546
+ { "uri": "urn:vte.cx:acl:+,CURDE" }
547
+ ],
548
+ "link": [{ "___rel": "self", "___href": "/_group/admin" }]
549
+ }
550
+ ```
551
+
552
+ ### メソッド一覧
553
+
554
+ | メソッド | 用途 |
555
+ | -------------------------------- | ---------------------------------------------- |
556
+ | `addGroup(group)` | 自分をグループに登録(グループがなければ作成) |
557
+ | `joinGroup(group)` | 管理者に招待されたグループへの参加確認 |
558
+ | `addGroupByAdmin(uids, group)` | 管理者が指定ユーザーをグループに追加 |
559
+ | `leaveGroup(group)` | グループから脱退 |
560
+ | `leaveGroupByAdmin(uids, group)` | 管理者がユーザーをグループから削除 |
561
+ | `isGroupMember(uri)` | ログインユーザーのグループ所属確認 |
562
+ | `getGroups()` | サービス内の全グループ一覧取得 |
563
+
564
+ ```typescript
565
+ await vtecxnext.addGroup('/_group/sales')
566
+ await vtecxnext.joinGroup('/_group/sales')
567
+ await vtecxnext.addGroupByAdmin([uid], '/_group/sales')
568
+ const isAdmin = await vtecxnext.isGroupMember('/_group/admin')
569
+ const groups = await vtecxnext.getGroups()
570
+ ```
571
+
572
+ ---
573
+
574
+ ## エイリアス(横断検索)
575
+
576
+ エイリアスは、同じエントリに対して複数のパスから参照する仕組みです。`link` 配列に `rel="alternate"` を追加することで設定します。
577
+
578
+ **エイリアスの主な用途は「逆引き」の実現です。** 例えば「あるユーザーが担当する顧客一覧」を取得したいとき、顧客側にインデックスを設けなくてもエイリアスパスで `getFeed` すれば顧客エントリが直接返ります。
579
+
580
+ ```
581
+ 一次パス(正引き): /crm/customer/{cid}/member/{uid} ← getFeed('/crm/customer/{cid}/member')
582
+ エイリアス(逆引き): /crm/member/{uid}/{cid} ← getFeed('/crm/member/{uid}')
583
+ ```
584
+
585
+ ### 登録
586
+
587
+ エイリアスは別エントリを作るのではなく、**既存エントリの `link` 配列に `rel="alternate"` を追加**します。親パスの登録とエントリ更新を同一 `put()` にまとめます(親パスを先頭に配置すること)。
588
+
589
+ ```typescript
590
+ const customer = await vtecxnext.getEntry(`/crm/customer/${cid}`)
591
+ const existingLinks = (customer as any)?.link ?? []
592
+
593
+ await vtecxnext.put({
594
+ feed: {
595
+ entry: [
596
+ { link: [{ ___rel: 'self', ___href: `/crm/member/${uid}` }], contributor }, // 親パス(先頭)
597
+ {
598
+ ...(customer as any),
599
+ link: [...existingLinks, { ___rel: 'alternate', ___href: `/crm/member/${uid}/${cid}` }],
600
+ },
601
+ ]
602
+ }
603
+ })
604
+ ```
605
+
606
+ ### 逆引き検索
607
+
608
+ ```typescript
609
+ const feed = await vtecxnext.getFeed(`/crm/member/${uid}`)
610
+ // feed は VtecxApp.Entry[](顧客エントリが直接返る。別途 getEntry 不要)
611
+ ```
612
+
613
+ ### 担当者一覧の取得
614
+
615
+ ```typescript
616
+ const customer = await vtecxnext.getEntry(`/crm/customer/${cid}`)
617
+ const memberUids = ((customer as any)?.link ?? [])
618
+ .filter((l: any) => l.___rel === 'alternate' && l.___href?.startsWith('/crm/member/'))
619
+ .map((l: any) => (l.___href as string).split('/')[3])
620
+ ```
621
+
622
+ ### 削除
623
+
624
+ ```typescript
625
+ const customer = await vtecxnext.getEntry(`/crm/customer/${cid}`)
626
+ const updatedLinks = ((customer as any)?.link ?? [])
627
+ .filter((l: any) => !(l.___rel === 'alternate' && l.___href === `/crm/member/${uid}/${cid}`))
628
+ await vtecxnext.put({ feed: { entry: [{ ...(customer as any), link: updatedLinks }] } })
629
+ ```
630
+
631
+ ### エイリアスパスの権限設定
632
+
633
+ エイリアスパスも `folderacls.json` に登録します。
634
+
635
+ ```json
636
+ { "contributor": [{ "uri": "urn:vte.cx:acl:/_group/$admin,CURD" }, { "uri": "urn:vte.cx:acl:+,CURDE" }],
637
+ "link": [{ "___rel": "self", "___href": "/crm/member" }] }
638
+ ```
639
+
640
+ ---
641
+
642
+ ## データ登録・更新
643
+
644
+ > **可能な限り `put()` の `feed.entry` にまとめて、API 呼び出しを1回にすること。**
645
+ > これは vte.cx を使った実装における最重要の設計・実装方針である。
646
+
647
+ | 観点 | 個別 PUT(複数回) | まとめた PUT(1回) |
648
+ | -------------- | ------------------------------------------ | -------------------- |
649
+ | パフォーマンス | N回のネットワーク往復 | 1回のみ |
650
+ | 原子性 | 途中でエラーが起きると中途半端な状態になる | 全件成功 or 全件失敗 |
651
+ | サーバー負荷 | リクエスト数が多い | 少ない |
652
+
653
+ ```typescript
654
+ // ✅ 複数エントリを1回の put() にまとめる
655
+ await vtecxnext.put({
656
+ feed: {
657
+ entry: [
658
+ { link: [{ ___rel: 'self', ___href: '/crm/customer/001' }], customer: {...}, contributor },
659
+ { link: [{ ___rel: 'self', ___href: '/crm/customer/002' }], customer: {...}, contributor },
660
+ ]
661
+ }
662
+ })
663
+ ```
664
+
665
+ ### ⚠️ 親子データは必ず親を先に配置する
666
+
667
+ 親パスが存在しない状態で子エントリを登録しようとすると `Parent path is required` エラーになる。
668
+
669
+ ```typescript
670
+ // ✅ 正しい順序: 親 → 子パス
671
+ await vtecxnext.put({
672
+ feed: {
673
+ entry: [
674
+ { link: [{ ___rel: 'self', ___href: '/crm/customer/001' }], customer: {...}, contributor },
675
+ { link: [{ ___rel: 'self', ___href: '/crm/customer/001/contact' }], contributor },
676
+ { link: [{ ___rel: 'self', ___href: '/crm/customer/001/activity' }], contributor },
677
+ ]
678
+ }
679
+ })
680
+ // ❌ 誤った順序: 子パスを先に置くと Parent path is required エラー
681
+ ```
682
+
683
+ ---
684
+
685
+ ## メール設定(properties.xml)
686
+
687
+ `setup/_settings/properties.xml` を編集して `pnpm upload:properties` を実行することで、ユーザー登録メールとパスワードリセットメールが有効になる。
688
+
689
+ ### `/_settings/properties` — サーバー設定
690
+
691
+ ```xml
692
+ <entry>
693
+ <link href="/_settings/properties" rel="self"/>
694
+ <rights>
695
+ _recaptcha.sitekey=your-recaptcha-site-key
696
+ _mail.user=apikey
697
+ _mail.password=SG.your-sendgrid-api-key
698
+ _mail.from=no-reply@your-domain.com
699
+ _mail.from.personal=Your Service Name
700
+ _mail.transport.protocol=smtps
701
+ _mail.smtp.host=smtp.sendgrid.net
702
+ _mail.smtp.port=587
703
+ _mail.smtp.auth=true
704
+ </rights>
705
+ </entry>
706
+ ```
707
+
708
+ | キー | 説明 |
709
+ | --------------------- | -------------------------------------------- |
710
+ | `_recaptcha.sitekey` | reCAPTCHA v2 サイトキー |
711
+ | `_mail.user` | SMTPユーザー名(SendGrid は固定値 `apikey`) |
712
+ | `_mail.password` | SMTPパスワード(SendGrid APIキー) |
713
+ | `_mail.from` | 送信元メールアドレス |
714
+ | `_mail.from.personal` | 送信者表示名 |
715
+ | `_mail.smtp.host` | SMTPホスト(SendGrid: `smtp.sendgrid.net`) |
716
+ | `_mail.smtp.port` | SMTPポート(`587`) |
717
+
718
+ ### `/_settings/adduser` — ユーザー登録確認メール
719
+
720
+ ```xml
721
+ <entry>
722
+ <link href="/_settings/adduser" rel="self"/>
723
+ <title>ユーザ登録のお申込み確認</title>
724
+ <subtitle>text</subtitle>
725
+ <summary>本登録URLをクリックしてください。
726
+
727
+ ${VTECXNEXT_URL}/signup-completion?_RXID=${RXID}
728
+
729
+ トップページ ${URL}</summary>
730
+ <content type="text/html"><![CDATA[<html>
731
+ <body>
732
+ <p>本登録URLをクリックしてください。</p>
733
+ <p><a href="${VTECXNEXT_URL}/signup-completion?_RXID=${RXID}">本登録はこちら</a></p>
734
+ <p>トップページ: <a href="${URL}">${URL}</a></p>
735
+ </body>
736
+ </html>]]></content>
737
+ </entry>
738
+ ```
739
+
740
+ ### `/_settings/passreset` — パスワードリセットメール
741
+
742
+ ```xml
743
+ <entry>
744
+ <link href="/_settings/passreset" rel="self"/>
745
+ <title>パスワード変更</title>
746
+ <subtitle>text</subtitle>
747
+ <summary>以下のURLをクリックしてパスワード変更を行ってください。
748
+
749
+ ${VTECXNEXT_URL}/change-password?${PASSRESET_TOKEN}</summary>
750
+ <content type="text/html"><![CDATA[<html>
751
+ <body>
752
+ <p>以下のURLをクリックしてパスワード変更を行ってください。</p>
753
+ <p><a href="${VTECXNEXT_URL}/change-password?${PASSRESET_TOKEN}">パスワード変更はこちら</a></p>
754
+ </body>
755
+ </html>]]></content>
756
+ </entry>
757
+ ```
758
+
759
+ | 変数 | 内容 |
760
+ | -------------------- | ---------------------------------------------------------------- |
761
+ | `${VTECXNEXT_URL}` | アプリのベースURL(`.env.local` の `NEXT_PUBLIC_VTECXNEXT_URL`) |
762
+ | `${RXID}` | 本登録トークン(フレームワークが自動生成) |
763
+ | `${PASSRESET_TOKEN}` | パスワードリセットトークン(フレームワークが自動生成) |
764
+
765
+ ---
766
+
767
+ ## 管理画面
768
+
769
+ 管理画面 URL: https://admin.vte.cx/index.html
770
+
771
+ > **各サービスの管理画面へのアクセス手順:** https://admin.vte.cx にログイン → サービス一覧から該当サービスの「管理画面」ボタンを押下 → そのサービスの管理画面(エンドポイント管理・ユーザー管理など)が開く。
772
+
773
+ ### エンドポイント管理
774
+
775
+ `folderacls.json` に相当するディレクトリパスとアクセス権限を管理する。`pnpm upload:folderacls` でアップロードした後、管理画面でも確認・追加編集が可能。
776
+
777
+ ### スキーマ管理
778
+
779
+ `template.xml` のアップロードで作成されたスキーマを管理画面から確認できる。インデックス・フィールド ACL・暗号化の設定は `template.xml` の `<rights>` タグに記述する。
780
+
781
+ ### データ確認
782
+
783
+ 登録したデータはエンドポイント管理から参照できる。
784
+
785
+ 1. https://admin.vte.cx/index.html にログイン
786
+ 2. サービス一覧から対象サービスの「管理画面」を押下
787
+ 3. 左メニューの「エンドポイント管理」をクリック
788
+
789
+ ---
790
+
791
+ ## よくあるエラー
792
+
793
+ ### Parent path is required.
794
+
795
+ ```
796
+ VtecxNextError: Parent path is required. /your/path
797
+ ```
798
+
799
+ **原因**: 登録しようとしたパスの親ディレクトリが vte.cx サーバーに存在しない。
800
+
801
+ **対処**: `folderacls.json` に不足している親パスを追加して `pnpm upload:folderacls` を実行する。
802
+
803
+ 例: `/crm/user/${uid}` への登録が失敗する場合 → `/crm` と `/crm/user` の両方が必要。
804
+
805
+ ---
806
+
807
+ ### Key is required.
808
+
809
+ ```
810
+ VtecxNextError: Key is required.
811
+ ```
812
+
813
+ **原因**: `put()` で送信するエントリに `link` が指定されていない。
814
+
815
+ **対処**: エントリに `link` を明示する。
816
+
817
+ ```typescript
818
+ const entry = {
819
+ link: [{ ___rel: 'self', ___href: '/crm/user/uid123' }],
820
+ user: { ... }
821
+ }
822
+ ```
823
+
824
+ #### `id` フィールドについて
825
+
826
+ | フィールド | 用途 | 形式 | 必須 |
827
+ | --------------------------- | -------------------------- | -------------------------------- | ---------- |
828
+ | `link[___rel=self].___href` | リソースのパス(キー)指定 | `/path/to/entry` | PUT で必須 |
829
+ | `id` | 楽観的排他制御(競合判定) | `{path},{revision}` 例: `/foo,3` | 任意 |
830
+
831
+ - `id` を含めると、サーバーが現在のリビジョンと照合し、不一致の場合 **409 Conflict** を返す
832
+ - `id` を省略すると競合チェックなしで強制上書き
833
+
834
+ ---
835
+
836
+ ### 403 Forbidden
837
+
838
+ **原因**: アクセス権限がない、またはセッションが切れている。
839
+
840
+ **対処**:
841
+
842
+ - セッション切れ → ログイン画面へ自動遷移
843
+ - `folderacls.json` のパスに権限が付与されていない → 権限設定を見直して `pnpm upload:folderacls` を再実行
844
+
845
+ > localhost と `https://{サービス名}.vte.cx` はセッションが独立している。localhost で確認する場合は localhost でログインし直す必要がある。
846
+
847
+ ---
848
+
849
+ ### 401 Unauthorized
850
+
851
+ **原因**: 認証情報が間違っている(IDまたはパスワードの誤り)。
852
+
853
+ ---
854
+
855
+ ### 204 No Content
856
+
857
+ **意味**: エラーではなく、**対象データが存在しない**ことを示す正常レスポンス。
858
+
859
+ **対処**: エラーとして扱わず、「データなし」の状態として処理する。
860
+
861
+ ```typescript
862
+ const entry = await vtecxnext.getEntry('/crm/customer/0000000001')
863
+ if (entry == null) {
864
+ // データが存在しない(204)
865
+ }
866
+ ```
867
+
868
+ ---
869
+
870
+ ### 409 Conflict
871
+
872
+ **原因**: `id`(リビジョン)が不一致(楽観的排他制御)。
873
+
874
+ **対処**: `getEntry` で最新取得し `id` を更新してから再 PUT する。
875
+
876
+ ---
877
+
878
+ ### Already specified
879
+
880
+ **原因**: 同一フィールドを複数行でインデックス設定している。
881
+
882
+ **対処**: `field:/path1|/path2` で1行にまとめる。