コンテンツにスキップ

同意システムプラグイン (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 側に寄せる。
  • ConsentRecordproductId に加えて variantId を持てるため、SKU 固有同意を個別に再利用判定できる。
  • recordConsent / denyConsent / getConsentFormData / getValidConsents / consentHistoryvariantId を受け付け、Variant を優先して解決する。
  • checkProductConsentRequirements / denyConsentvariantId 単独でも呼べ、必要ならサーバー側で親 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 でのテンプレート作成

  1. 同意テンプレート 画面で 新規追加
  2. 名称 / 同意タイプ / 同意本文 を入力
  3. 必要ならチェックリストを追加して保存

Storefront 実データ確認

  1. ログイン後、/products/exuviance-treatment-kit を開く
  2. カート追加で同意モーダルが表示されることを確認
  3. 同意チェック後に送信し、モーダルが閉じることを確認

環境注意(ローカル)

  • 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}

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. 注文を確定
   └─ 同意ステータスを「完了」に更新

セキュリティ考慮事項

  1. 同意記録の改ざん防止: すべての同意記録は追記のみ可能(削除不可)
  2. デジタル署名の検証: 署名データの整合性を検証
  3. アクセス制御: 同意記録は顧客本人と管理者のみアクセス可能
  4. 暗号化: デジタル署名データは暗号化して保存
  5. 監査ログ: すべての同意操作を監査ログに記録

パフォーマンス最適化

  • 同意要件のキャッシング: 商品の同意要件をRedisにキャッシュ
  • 有効性チェックの最適化: 有効な同意をメモリキャッシュ
  • バッチクリーンアップ: 期限切れ同意の定期的な一括処理

トラブルシューティング

よくある問題

問題: 同意が記録されない

  • 原因: 必須フィールドが不足
  • 解決: consentMethod, consentDetails を確認

問題: 同意が期限切れとして表示される

  • 原因: expiresAt の設定ミス
  • 解決: 同意書テンプレートの expirationMonths を確認

関連ドキュメント

今後の拡張予定

  • 多言語対応(英語同意書)
  • 複数証人署名機能
  • ビデオ同意記録機能
  • AI不正同意検出機能