同意システムプラグイン (Consent System Plugin)¶
概要¶
Ritsubiの医薬部外品・専門取扱商品に対する同意取得要件に対応するVendureカスタムプラグイン。グリコール酸、iMESO等の特定商品購入時の同意記録、法的保持期間管理(7年間)、デジタル署名対応を実現します。
互換性レンジ¶
- Vendure: 3.5.x(apps/vendure-server は ^3.5.2 を使用)
- Node.js: >=24.7.0(apps/vendure-server/package.json の engines に準拠)
パッケージ情報¶
- パッケージ名:
@ritsubi/consent-system-plugin - パス:
/packages/plugins/consent-system - エントリーポイント:
src/index.ts
実装機能¶
1. 商品 / SKU 別同意要件管理¶
特定の販売商品に対して、購入前に必要な同意タイプを定義する。2026-03以降は、Variant 優先 / Product フォールバック を採用する。
同意要件の優先順位¶
ProductVariant.customFields.consent*Product.customFields.consent*-
Collection / テンプレート既定
-
SKU 固有の同意は Variant に設定する。
- 商品全体で共通の同意は Product に設定する。
consentNoteHtml/consentChecklistも Variant に持てるため、SKU 固有の文面や確認項目は Variant 側に寄せる。ConsentRecordはproductIdに加えてvariantIdを持てるため、SKU 固有同意を個別に再利用判定できる。recordConsent/denyConsent/getConsentFormData/getValidConsents/consentHistoryはvariantIdを受け付け、Variant を優先して解決する。checkProductConsentRequirements/denyConsentはvariantId単独でも呼べ、必要ならサーバー側で親productIdを補完する。checkMultipleProductConsentsは input object 化せず、productIds/variantIdsの dual-array を受け付ける。- consent の GraphQL operation 定義は storefront ではなく
packages/sdk/shopを正本として管理する。
バンドル商品の判定単位¶
- バンドル商品では、バンドル親商品そのものではなく、構成商品ごとの同意要件を確認する。
- Storefront は構成商品の
variantIdを優先して、構成商品単位にcheckProductConsentRequirementsを実行する。 - 構成商品のうち既に同意済みのものは再同意不要とし、未同意の構成商品だけモーダル表示と記録を行う。
recordConsentには、バンドル親商品ではなく実際に同意対象となった構成商品のvariantId/productIdを渡す。
同意タイプ¶
| 同意タイプ | 説明 | 対象商品例 |
|---|---|---|
GLYCOLIC_ACID |
グリコール酸取扱同意 | エクスビアンス グリコール酸配合商品 |
IMESO |
iMESO取扱同意 | メソセウティカル iMESO商品 |
SPECIAL_HANDLING |
特別取扱注意事項 | 高濃度美容液等 |
PRESCRIPTION_REQUIRED |
処方箋確認 | 医療機関専用商品 |
AGE_VERIFICATION |
年齢確認 | 制限付き商品 |
PROFESSIONAL_USE_ONLY |
専門家使用限定 | エステ・クリニック専用商品 |
2. 同意記録の永続化¶
すべての同意記録をデータベースに保存し、法的保持期間(7年間)を管理。
記録される情報¶
- 同意内容: 同意書のテキストとバージョン
- 同意日時: 同意した正確な日時
- 同意方法: オンライン同意、書面アップロード等
- デジタル署名: 電子署名データ(オプション)
- アップロード書類: 同意書のPDF等(オプション)
3. 同意の有効性検証¶
同意の有効期限を管理し、期限切れの場合は再取得を要求。
// 同意の有効性チェック
const isValid = await consentService.isConsentValid(customerId, productId, "GLYCOLIC_ACID");
if (!isValid) {
// 再同意が必要
redirectToConsentForm();
}
4. GraphQL API提供¶
フロントエンド(Storefront)からの同意操作のための完全な GraphQL API。
主要クエリ¶
checkProductConsentRequirements: 商品の同意要件を確認bundleItems.productId: バンドル商品の構成商品IDを返し、構成商品単位の同意確認に利用consentHistory: 顧客の同意履歴を取得getValidConsents: 有効な同意一覧を取得getConsentTemplate: 同意書テンプレートを取得getConsentFormData: 同意フォーム表示用データを取得
主要ミューテーション¶
recordConsent: 同意を記録denyConsent: 同意を拒否revokeConsent: 同意を撤回
5. 期限切れ同意の自動管理¶
定期的に期限切れの同意を検出し、顧客に再取得を通知。
技術仕様¶
プラグイン設定オプション¶
export interface ConsentSystemPluginOptions {
/**
* 商品同意機能を有効にする
*/
enableProductConsent?: boolean;
/**
* 電子署名を有効にする
*/
enableElectronicSignature?: boolean;
/**
* 同意履歴機能を有効にする
*/
enableConsentHistory?: boolean;
/**
* 同意タイプ一覧
*/
consentTypes?: string[];
/**
* 同意記録の保持期間(年)
*/
retentionPeriodYears?: number;
/**
* クリーンアップ間隔(時間)
*/
cleanupIntervalHours?: number;
/**
* デジタル署名必須化
*/
digitalSignatureRequired?: boolean;
/**
* 書類アップロード必須化
*/
documentUploadRequired?: boolean;
}
プラグイン初期化¶
import { ConsentSystemPlugin } from "@ritsubi/consent-system-plugin";
export const config: VendureConfig = {
plugins: [
ConsentSystemPlugin.init({
enableProductConsent: true,
enableElectronicSignature: true,
enableConsentHistory: true,
consentTypes: [
"GLYCOLIC_ACID",
"IMESO",
"SPECIAL_HANDLING",
"PRESCRIPTION_REQUIRED",
"AGE_VERIFICATION",
"PROFESSIONAL_USE_ONLY",
],
retentionPeriodYears: 7, // 法的保持期間
cleanupIntervalHours: 24, // 毎日クリーンアップ
digitalSignatureRequired: false,
documentUploadRequired: false,
}),
],
};
エンティティ¶
ConsentRecordEntity¶
同意記録を管理するエンティティ。
@Entity()
export class ConsentRecordEntity extends VendureEntity {
@Column()
customerId: string;
@Column({ nullable: true })
productId: string;
@Column({ nullable: true })
orderId: string;
@Column()
consentType: string; // GLYCOLIC_ACID, IMESO等
@Column("enum", {
enum: ["PENDING", "CONSENTED", "DENIED", "REVOKED", "EXPIRED"],
})
consentStatus: ConsentStatus;
@Column({ nullable: true })
consentedAt: Date;
@Column({ nullable: true })
expiresAt: Date;
@Column()
consentMethod: string; // ONLINE, PAPER, PHONE等
@Column("jsonb")
consentDetails: Record<string, unknown>;
@Column("text", { nullable: true })
digitalSignature: string; // Base64エンコードされた署名データ
@Column("jsonb", { nullable: true })
uploadedDocuments: Array<{
filename: string;
url: string;
uploadedAt: Date;
}>;
@Column({ nullable: true })
consentVersion: string; // 同意書のバージョン
}
ConsentTemplateEntity¶
同意書テンプレートを管理するエンティティ。
@Entity()
export class ConsentTemplateEntity extends VendureEntity {
@Column()
name: string;
@Column()
consentType: string;
@Column()
version: string; // v1.0, v2.0等
@Column("text")
consentText: string; // 同意書本文
@Column("jsonb")
formConfiguration: Record<string, unknown>; // フォーム設定(JSON)
@Column()
validFrom: Date;
@Column({ nullable: true })
validTo: Date;
@Column({ default: true })
isActive: boolean;
@Column({ default: false })
isDefault: boolean;
}
サービス¶
ConsentService¶
同意管理のコアロジックを担当するサービス。
主要メソッド:
checkProductConsentRequirements(productId: ID, customerId?: ID): Promise<ConsentRequirementResult>- 商品の同意要件をチェック
recordConsent(input: RecordConsentInput): Promise<ConsentRecordEntity>- 同意を記録
getConsentHistory(customerId: ID, productId?: ID): Promise<ConsentRecordEntity[]>- 同意履歴を取得
isConsentValid(customerId: ID, productId: ID, consentType: string): Promise<boolean>- 同意の有効性を検証
revokeConsent(consentId: ID, reason: string): Promise<void>- 同意を撤回
getConsentTemplate(consentType: string): Promise<ConsentTemplateEntity>- 同意書テンプレートを取得
カスタムフィールド¶
Product¶
consentRequired(boolean): 同意が必要consentTypes(string[]): 必要な同意タイプconsentInstructions(string): 同意取得時の注意事項consentTemplateId(string): 優先適用する同意テンプレートID
Order¶
hasConsentRequiredItems(boolean): 同意必要商品含むconsentStatus(string): 注文全体の同意ステータス
Seed と動作確認¶
Seed で投入される同意関連データ¶
- 同意テンプレート seed:
apps/vendure-server/src/data/fixtures/consent-template-seeds.ts - 同意必須商品 seed:
apps/vendure-server/src/data/fixtures/consent-required-product-seeds.ts - 既定では
exuviance-treatment-kitを同意必須商品として設定 consentRequired=true,consentTypes=["IMESO"],consentTemplateIdを付与slugが重複している商品がある場合は、該当slugの全商品に反映される
ローカル実行¶
just vendure-db-seed-local
Vendure Dashboard でのテンプレート作成¶
同意テンプレート画面で新規追加名称/同意タイプ/同意本文を入力- 必要ならチェックリストを追加して保存
Storefront 実データ確認¶
- ログイン後、
/products/exuviance-treatment-kitを開く - カート追加で同意モーダルが表示されることを確認
- 同意チェック後に送信し、モーダルが閉じることを確認
環境注意(ローカル)¶
- Storefront が参照する Vendure のURLは
VITE_PUBLIC_VENDURE_BASE_URLで決まる - 同意検証時は、seedを投入したVendureと同じURLを参照していることを必ず確認する
- 例:
- Portless:
VITE_PUBLIC_VENDURE_BASE_URL=http://vendure.localhost:<worktree-proxy-port> - localhost:
VITE_PUBLIC_VENDURE_BASE_URL=http://localhost:${VENDURE_PORT:-3021}
- Portless:
GraphQL スキーマ¶
主要クエリ¶
type Query {
checkProductConsentRequirements(productId: ID!, customerId: ID): ProductConsentCheckResult!
consentHistory(customerId: ID!, productId: ID): [ConsentRecord!]!
getValidConsents(customerId: ID!, productId: ID, consentTypes: [String!]): [ConsentRecord!]!
getConsentTemplate(consentType: String!): ConsentTemplate
getConsentFormData(consentType: String!, productId: ID): ConsentFormPayload!
}
主要ミューテーション¶
type Mutation {
recordConsent(
customerId: ID!
productId: ID
orderId: ID
consentType: String!
consentMethod: String!
consentDetails: JSON!
digitalSignature: String
uploadedDocuments: JSON
): ConsentRecord!
denyConsent(
customerId: ID!
productId: ID!
consentType: String!
reason: String!
): ConsentRecord!
revokeConsent(consentId: ID!, revocationReason: String!): ConsentRecord!
}
対応する要件¶
要件定義書との対応¶
- グリコール酸取扱同意: エクスビアンス商品購入時の同意取得
- iMESO取扱同意: メソセウティカル商品購入時の同意取得
- 法的保持期間: 同意記録の7年間保管義務
- 電子署名対応: デジタル署名による法的有効性確保
使用例¶
フロントエンド(Storefront)での使用¶
import { useQuery, useMutation } from "@tanstack/react-query";
import { graphql } from "@/__generated__/gql";
import { vendureQueryClient } from "@/lib/vendure-fetch-client";
const CHECK_CONSENT_REQUIREMENTS = graphql(`
query CheckConsentRequirements($productId: ID!, $customerId: ID!) {
requiresConsent(productId: $productId, customerId: $customerId)
consentTypes(productId: $productId, customerId: $customerId)
existingConsents(productId: $productId, customerId: $customerId) {
consentType
consentStatus
}
}
`);
// 商品の同意要件を確認
const { data } = useQuery({
queryKey: ["consentRequirements", "glycolic-acid-product", currentUser.id],
queryFn: () =>
vendureQueryClient(CHECK_CONSENT_REQUIREMENTS, {
productId: "glycolic-acid-product",
customerId: currentUser.id,
}),
});
if (data?.requiresConsent) {
console.log(`必要な同意: ${data.consentTypes.join(", ")}`);
// 既存の同意をチェック
if (data.existingConsents.length === 0) {
// 同意フォームを表示
showConsentForm(data.consentTypes);
}
}
同意の記録¶
import { useMutation } from "@tanstack/react-query";
import { graphql } from "@/__generated__/gql";
import { vendureQueryClient } from "@/lib/vendure-fetch-client";
const RECORD_CONSENT = graphql(`
mutation RecordConsent(
$customerId: ID!
$productId: ID!
$consentType: String!
$consentMethod: String!
$consentDetails: JSON
$digitalSignature: String
) {
recordConsent(
customerId: $customerId
productId: $productId
consentType: $consentType
consentMethod: $consentMethod
consentDetails: $consentDetails
digitalSignature: $digitalSignature
)
}
`);
// 同意を記録
const { mutateAsync: recordConsent } = useMutation({
mutationFn: (variables: {
customerId: string;
productId: string;
consentType: string;
consentMethod: string;
consentDetails: {
ipAddress: string;
userAgent: string;
timestamp: string;
};
digitalSignature?: string;
}) => vendureQueryClient(RECORD_CONSENT, variables),
});
await recordConsent({
customerId: currentUser.id,
productId: "glycolic-acid-product",
consentType: "GLYCOLIC_ACID",
consentMethod: "ONLINE",
consentDetails: {
ipAddress: "192.168.1.1",
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
},
digitalSignature: signatureDataUrl, // Canvas署名データ
});
同意履歴の確認¶
import { useQuery } from "@tanstack/react-query";
import { graphql } from "@/__generated__/gql";
import { vendureQueryClient } from "@/lib/vendure-fetch-client";
const GET_CONSENT_HISTORY = graphql(`
query GetConsentHistory($customerId: ID!) {
consentHistory(customerId: $customerId) {
consentType
consentStatus
consentedAt
expiresAt
}
}
`);
// 顧客の同意履歴を取得
const { data } = useQuery({
queryKey: ["consentHistory", currentUser.id],
queryFn: () =>
vendureQueryClient(GET_CONSENT_HISTORY, {
customerId: currentUser.id,
}),
});
data?.consentHistory.forEach((consent) => {
console.log(`${consent.consentType}: ${consent.consentStatus}`);
console.log(`同意日: ${consent.consentedAt}`);
console.log(`有効期限: ${consent.expiresAt}`);
});
管理画面での使用¶
// 同意書テンプレートを作成
await consentService.createTemplate({
name: "グリコール酸取扱同意書 v2.0",
consentType: "GLYCOLIC_ACID",
version: "v2.0",
consentText: "...",
formConfiguration: {
requiresSignature: true,
requiresWitnessSignature: false,
expirationMonths: 12,
},
validFrom: new Date(),
});
// 期限切れ同意の確認
const expiring = await consentService.getExpiringSoonConsents(30); // 30日以内
console.log(`${expiring.length}件の同意が期限切れ間近です`);
同意取得フロー¶
商品購入時の同意フロー¶
1. カートに商品追加
└─ 同意要件をチェック
2. 同意が必要な場合
├─ 既存の有効な同意を確認
├─ 有効な同意がない場合
│ └─ 同意フォームを表示
└─ 顧客が同意
3. 同意を記録
├─ デジタル署名を保存
├─ 同意詳細を記録
└─ 注文に同意IDを関連付け
4. 注文を確定
└─ 同意ステータスを「完了」に更新
セキュリティ考慮事項¶
- 同意記録の改ざん防止: すべての同意記録は追記のみ可能(削除不可)
- デジタル署名の検証: 署名データの整合性を検証
- アクセス制御: 同意記録は顧客本人と管理者のみアクセス可能
- 暗号化: デジタル署名データは暗号化して保存
- 監査ログ: すべての同意操作を監査ログに記録
パフォーマンス最適化¶
- 同意要件のキャッシング: 商品の同意要件をRedisにキャッシュ
- 有効性チェックの最適化: 有効な同意をメモリキャッシュ
- バッチクリーンアップ: 期限切れ同意の定期的な一括処理
トラブルシューティング¶
よくある問題¶
問題: 同意が記録されない
- 原因: 必須フィールドが不足
- 解決:
consentMethod,consentDetailsを確認
問題: 同意が期限切れとして表示される
- 原因:
expiresAtの設定ミス - 解決: 同意書テンプレートの
expirationMonthsを確認
関連ドキュメント¶
- 顧客管理プラグイン - 顧客資格情報との連携
- SMILE連携プラグイン - 同意記録のCSV出力
今後の拡張予定¶
- 多言語対応(英語同意書)
- 複数証人署名機能
- ビデオ同意記録機能
- AI不正同意検出機能