1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

パーソナルジム HP 向け『LocalBusiness + Service + AggregateRating JSON-LD』を Next.js 14 + Supabase Storage で出すミニ実装 ─ Before/After 写真の Signed URL を同意取消で即時失効する

1
Posted at

はじめに

個人で運営しているパーソナルジムや、フリーランスのパーソナルトレーナーの HP に、お客様の体験談(Voice)と Before/After 写真を載せたい。実装で詰まりやすいのは、次の 3 点です。

  • 構造化データ(JSON-LD)の選択肢が多いPerson で出すのか、LocalBusiness で出すのか、Service でも出すのか。Google 検索の星表示候補(Review snippet)に乗せるためには itemReviewed の紐付けが正しい必要がある
  • Before/After 写真の同意取消の即時性:お客様から取消依頼があったとき、CDN・ブラウザキャッシュに残った旧 URL が引き続き表示されてしまうと、運用が破綻する
  • 景表法の優良誤認表示の機械的検出:体験談本文に「絶対痩せる」「-10kg 保証」「100% 変わる」のような身体的効果の断定が混入しないように、CI で機械的に弾く仕組みがあると安心

本記事では Next.js 14 App Router + Supabase Storage を前提に、次を実装します。

  • 体験談データを「本文 + 公開メタ情報 + Before/After 写真の同意レベル」で持つ
  • LocalBusiness(パーソナルジム自体)+ Service(提供メニュー)+ AggregateRating(3 件以上で出力)+ Review を、itemReviewed で正しく紐付けて <script type="application/ld+json"> に出す
  • Before/After 写真は Supabase Storage の Signed URL を短い TTL(1 時間)+ DB 側の withdrawn_at チェックで配信し、同意取消依頼で即時失効させる
  • Vitest で「同意取消後は Signed URL を発行しない」という不変条件をユニットテスト化する

「自分で組まずに済ませたい」方は、同じ機能を SaaS 化した koe.arere.jp でも提供しています(収集フォーム作成・テスティモニアル管理・埋め込みウィジェット・JSON-LD 自動生成は全プラン無料)。実装のメンタルモデルとしてご一読いただければ。

1. データモデル:撮影同意と公開同意を別カラムに分ける

パーソナルトレーニングの Before/After 写真は、撮影記録のための撮影同意と、HP/SNS 公開のための公開同意が異なるレイヤーです。これを 1 カラムでまとめると、後で「撮影だけ OK・公開は NG」というよくあるパターンが扱えなくなるため、最初から分けて持ちます。

// types/testimonial.ts
export type ConsentLevel = "A" | "B" | "C" | "D";
// A=都道府県・年代・属性のみ / B=イニシャル+年代+属性 / C=氏名+年代+お顔写真 / D=非公開

export type PhotoExposure = "none" | "mosaic" | "neck_down" | "full";
// none=写真非公開 / mosaic=モザイク / neck_down=首から下のみ / full=全身公開

export interface PtTestimonial {
  id: string;
  body: string;                         // 本人記入の体験談本文
  consent_level: ConsentLevel;          // 文面の公開許諾レベル
  photo_exposure: PhotoExposure;        // 写真の公開可否(文面とは独立)
  age_band: "20s" | "30s" | "40s" | "50s" | "60s+";
  attribute: string;                    // "都内勤務" | "在宅ワーク" | "学生" 等
  rating?: 1 | 2 | 3 | 4 | 5;           // 任意
  prefecture?: string;                  // B/C のみ
  initials?: string;                    // B のみ("K.M." 等)
  full_name?: string;                   // C のみ
  photo_storage_path?: string;          // Supabase Storage の path("voices/2026/05/abc.jpg")
  acquired_at: string;                  // ISO 8601 取得日
  consent_expires_at?: string;          // 任意(期限なしなら undefined)
  withdrawn_at?: string;                // 取消依頼受領日(あれば公開フィードから除外)
}

ポイントは consent_levelphoto_exposure別カラムに分けている点です。同じ取消フォームから同時に変更されることもあれば、片方だけ変更されることもあります(例:写真は引き続き OK だが文面は匿名化レベルを上げたい)。

2. 公開フィードのフィルタ:withdrawn_at と consent_expires_at を一箇所に集める

Review の JSON-LD と画面表示の両方で、取消・期限切れの判定を同じソースから引きたいので、フィルタ関数を 1 つにまとめます。

// lib/testimonials/visible.ts
import type { PtTestimonial } from "@/types/testimonial";

export const isVisible = (t: PtTestimonial, now: Date = new Date()): boolean => {
  if (t.consent_level === "D") return false;        // 非公開選択
  if (t.withdrawn_at) return false;                  // 取消済
  if (t.consent_expires_at && new Date(t.consent_expires_at) < now) return false;
  return true;
};

サーバーから取得した直後にこの isVisible() でフィルタしてから、画面と JSON-LD の両方に渡します。now を引数で受けるのは、後の Vitest で時刻を固定してテストするためです。

3. LocalBusiness + Service + AggregateRating + Review の JSON-LD を組む

itemReviewed の紐付け先は LocalBusiness でも Service でも有効ですが、個人パーソナルジムでは LocalBusiness を主体にして AggregateRating をそこに付け、各 ReviewitemReviewedLocalBusiness を指す構成が、Google Search Central の Review snippet の例示と整合しやすいです。

// lib/jsonld/buildPtJsonLd.ts
import type { PtTestimonial, ConsentLevel } from "@/types/testimonial";
import { isVisible } from "@/lib/testimonials/visible";

const authorName = (t: PtTestimonial): string => {
  switch (t.consent_level) {
    case "C": return t.full_name ?? `${t.age_band} ${t.attribute}`;
    case "B": return `${t.initials ?? ""} ${t.age_band} ${t.attribute}`.trim();
    case "A":
    default:  return `${t.age_band} ${t.attribute}`;
  }
};

interface BuildArgs {
  business: {
    name: string;          // 屋号(例: "Koe Personal Studio 渋谷")
    url: string;           // HP の絶対 URL
    telephone?: string;
    address: {
      streetAddress: string;
      addressLocality: string;     // 例: "渋谷区"
      addressRegion: string;       // 例: "東京都"
      postalCode: string;
      addressCountry: "JP";
    };
    openingHours?: string[]; // 例: ["Mo-Fr 10:00-21:00", "Sa 10:00-18:00"]
  };
  service: {
    name: string;          // 例: "パーソナルトレーニング 60 分セッション"
    description: string;
    areaServed: string;    // 例: "東京都渋谷区"
  };
  testimonials: PtTestimonial[];
}

export const buildPtJsonLd = (args: BuildArgs, now: Date = new Date()) => {
  const visible = args.testimonials.filter((t) => isVisible(t, now));
  const reviews = visible.map((t) => ({
    "@type": "Review",
    "@id": `${args.business.url}#review-${t.id}`,
    itemReviewed: { "@id": `${args.business.url}#business` },
    author: { "@type": "Person", name: authorName(t) },
    reviewBody: t.body,
    datePublished: t.acquired_at,
    ...(t.rating ? { reviewRating: { "@type": "Rating", ratingValue: t.rating, bestRating: 5 } } : {}),
  }));

  // AggregateRating は 3 件以上 + 星評価ありで出す
  const rated = visible.filter((t) => typeof t.rating === "number");
  const aggregate =
    rated.length >= 3
      ? {
          "@type": "AggregateRating",
          itemReviewed: { "@id": `${args.business.url}#business` },
          ratingValue: (rated.reduce((s, t) => s + (t.rating ?? 0), 0) / rated.length).toFixed(2),
          reviewCount: rated.length,
          bestRating: 5,
        }
      : undefined;

  return {
    "@context": "https://schema.org",
    "@graph": [
      {
        "@type": "LocalBusiness",
        "@id": `${args.business.url}#business`,
        name: args.business.name,
        url: args.business.url,
        telephone: args.business.telephone,
        address: { "@type": "PostalAddress", ...args.business.address },
        openingHoursSpecification: args.business.openingHours,
        ...(aggregate ? { aggregateRating: aggregate } : {}),
      },
      {
        "@type": "Service",
        "@id": `${args.business.url}#service`,
        name: args.service.name,
        description: args.service.description,
        areaServed: args.service.areaServed,
        provider: { "@id": `${args.business.url}#business` },
      },
      ...reviews,
    ],
  };
};

3 件以上で AggregateRating を出すのは、Google Search Central の Review snippet 仕様と整合性のある運用です(件数が少なすぎると星表示候補から外れやすい)。

4. Server Component で <script type="application/ld+json"> に出す

Next.js 14 App Router の Server Component では、<script> を直接レンダリングできます(クライアント側で評価されないので XSS の論点は JSON.stringify</script> エスケープのみ)。

// app/(marketing)/page.tsx
import { fetchVisibleTestimonials } from "@/lib/testimonials/fetch";
import { buildPtJsonLd } from "@/lib/jsonld/buildPtJsonLd";
import { VoiceList } from "@/components/VoiceList";

const business = {
  name: "Koe Personal Studio 渋谷",
  url: "https://example.com",
  telephone: "+81-3-XXXX-XXXX",
  address: {
    streetAddress: "X-X-X",
    addressLocality: "渋谷区",
    addressRegion: "東京都",
    postalCode: "150-0001",
    addressCountry: "JP" as const,
  },
  openingHours: ["Mo-Fr 10:00-21:00", "Sa 10:00-18:00"],
};

const service = {
  name: "パーソナルトレーニング 60 分セッション",
  description: "個別カウンセリング + 動作評価 + メニュー設計の 60 分セッション。",
  areaServed: "東京都渋谷区",
};

export default async function Home() {
  const testimonials = await fetchVisibleTestimonials();
  const jsonLd = buildPtJsonLd({ business, service, testimonials });
  // </script> の混入を防ぐために置換
  const safe = JSON.stringify(jsonLd).replace(/</g, "\\u003c");

  return (
    <>
      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: safe }} />
      <VoiceList items={testimonials} />
    </>
  );
}

fetchVisibleTestimonials() は次節で withdrawn_at を SQL レベルで弾く形で実装します。Server Component 経由で出力されるため、JS を切ったブラウザ・Googlebot のいずれにも JSON-LD が届きます。

5. Supabase Storage の Signed URL を「短い TTL + 都度発行」で配信する

Before/After 写真は Public バケットに置くと取消が効きません(公開 URL は CDN にキャッシュされ続ける)。Private バケットに置き、各リクエスト時に TTL 1 時間 + DB の withdrawn_at チェックを経た Signed URL を都度発行します。

// lib/testimonials/fetch.ts
import { createClient } from "@/lib/supabase/server";
import type { PtTestimonial } from "@/types/testimonial";
import { isVisible } from "./visible";

const SIGNED_URL_TTL_SEC = 60 * 60; // 1 時間

interface ResolvedTestimonial extends PtTestimonial {
  photo_signed_url?: string;
}

export const fetchVisibleTestimonials = async (): Promise<ResolvedTestimonial[]> => {
  const supabase = createClient();
  const { data, error } = await supabase
    .from("pt_testimonials")
    .select("*")
    .is("withdrawn_at", null)
    .neq("consent_level", "D")
    .order("acquired_at", { ascending: false });
  if (error) throw error;

  const now = new Date();
  const visible = (data as PtTestimonial[]).filter((t) => isVisible(t, now));

  // Signed URL を都度発行(写真公開可否 photo_exposure !== 'none' の場合のみ)
  const resolved = await Promise.all(
    visible.map(async (t): Promise<ResolvedTestimonial> => {
      if (t.photo_exposure === "none" || !t.photo_storage_path) return t;
      const { data: signed, error: e } = await supabase.storage
        .from("voices")
        .createSignedUrl(t.photo_storage_path, SIGNED_URL_TTL_SEC);
      if (e) return t; // 発行失敗時は写真なしで返す
      return { ...t, photo_signed_url: signed.signedUrl };
    })
  );
  return resolved;
};

ポイントは 3 つです。

  • DB の withdrawn_at IS NULL で 1 段目フィルタ(取消済みのレコードは Signed URL すら作らない)
  • isVisible() で 2 段目フィルタ(期限切れも弾く。isVisible() を SQL でも書けますが、画面側と同じ関数を使うことで挙動の一致を担保しやすい)
  • TTL 1 時間 + 都度発行(取消依頼を受けてから最大 1 時間で旧 URL は失効)

「即時に失効したい」場合は TTL を 5 分まで縮められますが、CDN/ブラウザのキャッシュ層を考えると 1 時間程度が現実的なバランスです。完全な即時失効が必要な要件があるなら、Cloudflare Workers や Next.js Route Handler を経由して都度署名する API パスに切り替える設計を選びます。

6. Vitest で「同意取消後は Signed URL を発行しない」を不変条件にする

実装の不変条件を Vitest でユニットテスト化します。Supabase Storage を fake する形で書けば、ネット経由のテストは不要です。

// lib/testimonials/visible.test.ts
import { describe, it, expect } from "vitest";
import { isVisible } from "./visible";
import type { PtTestimonial } from "@/types/testimonial";

const base: PtTestimonial = {
  id: "t1",
  body: "セッション後、肩の動きがスムーズになりました。",
  consent_level: "A",
  photo_exposure: "none",
  age_band: "30s",
  attribute: "都内勤務",
  acquired_at: "2026-05-01T00:00:00+09:00",
};

describe("isVisible()", () => {
  it("通常レベル A は表示する", () => {
    expect(isVisible(base)).toBe(true);
  });

  it("consent_level=D は隠す(明示非公開)", () => {
    expect(isVisible({ ...base, consent_level: "D" })).toBe(false);
  });

  it("withdrawn_at が立っているレコードは隠す(取消済)", () => {
    expect(isVisible({ ...base, withdrawn_at: "2026-05-04T10:00:00+09:00" })).toBe(false);
  });

  it("consent_expires_at を過ぎたレコードは隠す", () => {
    const t = { ...base, consent_expires_at: "2026-05-04T00:00:00+09:00" };
    const now = new Date("2026-05-05T00:00:00+09:00");
    expect(isVisible(t, now)).toBe(false);
  });
});

加えて、buildPtJsonLd() 側にも以下のテストを置きます。

// lib/jsonld/buildPtJsonLd.test.ts
import { describe, it, expect } from "vitest";
import { buildPtJsonLd } from "./buildPtJsonLd";

const business = {
  name: "Test Studio",
  url: "https://example.com",
  address: {
    streetAddress: "1-1",
    addressLocality: "渋谷区",
    addressRegion: "東京都",
    postalCode: "150-0001",
    addressCountry: "JP" as const,
  },
};
const service = { name: "PT 60min", description: "...", areaServed: "東京都" };

describe("buildPtJsonLd()", () => {
  it("取消済レコードは Review として出力されない", () => {
    const jsonLd = buildPtJsonLd({
      business,
      service,
      testimonials: [
        {
          id: "t1",
          body: "良かった",
          consent_level: "A",
          photo_exposure: "none",
          age_band: "30s",
          attribute: "都内勤務",
          acquired_at: "2026-05-01T00:00:00+09:00",
          withdrawn_at: "2026-05-03T00:00:00+09:00",
        },
      ],
    });
    const reviews = jsonLd["@graph"].filter((n: { "@type": string }) => n["@type"] === "Review");
    expect(reviews).toHaveLength(0);
  });

  it("AggregateRating は 3 件以上の rating ありレビューでのみ出力される", () => {
    const make = (id: string, rating?: 1 | 2 | 3 | 4 | 5) => ({
      id,
      body: "x",
      consent_level: "A" as const,
      photo_exposure: "none" as const,
      age_band: "30s" as const,
      attribute: "都内勤務",
      rating,
      acquired_at: "2026-05-01T00:00:00+09:00",
    });
    const twoStarred = buildPtJsonLd({
      business,
      service,
      testimonials: [make("t1", 5), make("t2", 4)],
    });
    const business1 = twoStarred["@graph"].find((n: { "@type": string }) => n["@type"] === "LocalBusiness");
    expect((business1 as { aggregateRating?: unknown }).aggregateRating).toBeUndefined();

    const threeStarred = buildPtJsonLd({
      business,
      service,
      testimonials: [make("t1", 5), make("t2", 4), make("t3", 5)],
    });
    const business2 = threeStarred["@graph"].find((n: { "@type": string }) => n["@type"] === "LocalBusiness");
    expect((business2 as { aggregateRating?: unknown }).aggregateRating).toBeDefined();
  });

  it("itemReviewed が LocalBusiness の @id を指している", () => {
    const jsonLd = buildPtJsonLd({
      business,
      service,
      testimonials: [
        {
          id: "t1",
          body: "x",
          consent_level: "A",
          photo_exposure: "none",
          age_band: "30s",
          attribute: "都内勤務",
          acquired_at: "2026-05-01T00:00:00+09:00",
        },
      ],
    });
    const review = jsonLd["@graph"].find((n: { "@type": string }) => n["@type"] === "Review");
    expect((review as { itemReviewed: { "@id": string } }).itemReviewed["@id"]).toBe(`${business.url}#business`);
  });
});

このテストが通っていれば、後から「とりあえず取消済レコードは表示しないつもりだった」のような暗黙の前提が壊れたときに、CI で気付けます。

まとめ

実装の要点を 3 つに絞ると次のとおりです。

  • consent_levelphoto_exposure を別カラムに分ける:撮影同意と公開同意・文面と写真は別レイヤー。1 つにまとめると後で扱えなくなる
  • itemReviewedLocalBusiness に紐付ける:個人パーソナルジムでは Person よりも LocalBusiness(屋号)に集約し、その上に ServiceAggregateRating を載せる構成が、Review snippet 仕様と整合的
  • Signed URL は短い TTL + DB 側の withdrawn_at チェック:取消依頼から最大 1 時間で旧 URL を失効させる。完全即時が要件なら Route Handler 経由の都度署名 API に切り替える

景品表示法の優良誤認表示の論点(「絶対痩せる」「-10kg 保証」等)は、本記事のコードレベルでは扱っていません。本文の機械的な NG 検出は別途、レビュー保存時のサーバーサイドチェック(保存前の flagRiskyBody() のような関数)で弾く形が現実的です(別記事で扱う想定)。

「自分で組まずに済ませたい」方は、同じ機能を SaaS 化した koe.arere.jp でも提供しています(収集フォーム作成・テスティモニアル管理・埋め込みウィジェット・LocalBusiness/Organization JSON-LD 自動生成は全プラン無料)。

参考資料

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?