指摘ありがとうございます。たしかに重要な点は:

  • family[].relationshipmainOwner.relationshipどちらもログインユーザーから見た続柄 であり、 そのまま比較しても「被保険者(mainOwner)から見た」関係にならないことです。
  • しかし今回の判定(本人 / 配偶者 / 同居の家族)が目的なので、続柄を厳密に「被保険者基準」に変換するのではなく、ログインユーザー基準の relationshiplivingStatus を組み合わせて「被保険者にとって本人か・配偶者か・同居家族か」になるかを判断するのが実用的かつシンプルです。

以下はその考えを実装した修正版 isCoveredDriver と、指摘の例(被保険者が「同居する実親」で運転者が「本人」)を含む Vitest の網羅的テストケース です。


修正版 isCoveredDriver 実装(TypeScript)

export const isCoveredDriver = (
  driverScope:
    | "本人"
    | "本人+配偶者"
    | "本人+配偶者+同居の家族"
    | "なし"
    | undefined,
  family: {
    personalId: string;
    livingStatus: "同居" | "別居" | undefined;
    relationship:
      | "配偶者"
      | "兄弟"
      | "実親"
      | "義親"
      | "本人"
      | "その他"
      | undefined;
  }[],
  mainOwner: {
    personalId: string;
    livingStatus: "同居" | "別居" | undefined;
    relationship:
      | "配偶者"
      | "兄弟"
      | "実親"
      | "義親"
      | "本人"
      | "その他"
      | undefined;
  }
): boolean => {
  // driverScope が undefined / "なし" は誰も対象外
  if (!driverScope || driverScope === "なし") return false;

  const isMainOwner = (pId: string) => pId === mainOwner.personalId;

  /**
   * mainOwner と driver が「同居している」と判定するルール
   * - 両者ともログインユーザー視点で "同居" であれば同一世帯(=同居)とみなす
   * - また、driver がログインユーザー本人 (relationship === "本人")
   *   で mainOwner.livingStatus === "同居" なら mainOwner と同居している
   * - 同様に mainOwner が "本人" で driver.livingStatus === "同居" なら同居
   */
  const isCohabitingWithMainOwner = (driver: typeof family[number]) => {
    if (mainOwner.livingStatus === "同居" && driver.livingStatus === "同居")
      return true;

    if (driver.relationship === "本人" && mainOwner.livingStatus === "同居")
      return true;

    if (mainOwner.relationship === "本人" && driver.livingStatus === "同居")
      return true;

    return false;
  };

  /**
   * mainOwner の配偶者かどうかを簡易判定するルール
   * (ログインユーザー視点の続柄を組み合わせて推論する)
   *
   * ケース例(ログインユーザーを U、被保険者を M、運転者を D):
   * - M.relationship === "本人" && D.relationship === "配偶者"  => D は M の配偶者
   * - M.relationship === "配偶者" && D.relationship === "本人"  => D は M の配偶者
   *
   * 上記以外は保守的に false とする(曖昧なケースで誤判定を防ぐ)。
   */
  const isSpouseOfMainOwner = (driver: typeof family[number]) => {
    if (driver.relationship === "配偶者" && mainOwner.relationship === "本人")
      return true;
    if (driver.relationship === "本人" && mainOwner.relationship === "配偶者")
      return true;

    return false;
  };

  const isCoveredOne = (driver: typeof family[number]) => {
    if (isMainOwner(driver.personalId)) return true;

    switch (driverScope) {
      case "本人":
        return isMainOwner(driver.personalId);

      case "本人+配偶者":
        return isMainOwner(driver.personalId) || isSpouseOfMainOwner(driver);

      case "本人+配偶者+同居の家族":
        return (
          isMainOwner(driver.personalId) ||
          isSpouseOfMainOwner(driver) ||
          isCohabitingWithMainOwner(driver)
        );

      default:
        return false;
    }
  };

  // family に含まれる全員が covered であることが必要(1人でも範囲外なら false)
  return family.every(isCoveredOne);
};

Vitest テスト(網羅的・指摘の例を含む)

import { describe, it, expect } from "vitest";
import { isCoveredDriver } from "./isCoveredDriver"; // 実際のパスに合わせて修正

const mk = (
  id: string,
  living: "同居" | "別居" | undefined,
  rel:
    | "配偶者"
    | "兄弟"
    | "実親"
    | "義親"
    | "本人"
    | "その他"
    | undefined
) => ({ personalId: id, livingStatus: living, relationship: rel });

describe("isCoveredDriver(修正版)", () => {
  const OWNER = mk("OWNER", "同居", "本人");

  describe("driverScope = なし / undefined", () => {
    it("なし -> false", () => {
      expect(isCoveredDriver("なし", [mk("OWNER", "同居", "本人")], OWNER)).toBe(false);
    });
    it("undefined -> false", () => {
      expect(isCoveredDriver(undefined, [mk("OWNER", "同居", "本人")], OWNER)).toBe(false);
    });
  });

  describe("driverScope = 本人", () => {
    it("被保険者本人のみ OK", () => {
      expect(isCoveredDriver("本人", [mk("OWNER", "同居", "本人")], OWNER)).toBe(true);
    });
    it("ログインユーザー(本人)だが被保険者が同居実親の場合 -> NOT OK(本人限定)", () => {
      // mainOwner はログインユーザー視点で実親(=被保険者は user の親)
      const mainOwner = mk("PARENT", "同居", "実親");
      const driverUser = mk("ME", undefined, "本人");
      // driver は mainOwner にとって「子」だが driverScope が "本人" のため NG
      expect(isCoveredDriver("本人", [driverUser], mainOwner)).toBe(false);
    });
  });

  describe("driverScope = 本人+配偶者", () => {
    it("被保険者本人 OR 配偶者 を許可", () => {
      const mainOwner = mk("ME", "同居", "本人");
      expect(isCoveredDriver("本人+配偶者", [mk("ME", "同居", "本人")], mainOwner)).toBe(true);
      expect(isCoveredDriver("本人+配偶者", [mk("SPOUSE", "同居", "配偶者")], mainOwner)).toBe(true);
    });

    it("ログインユーザーが被保険者の配偶者のケース(mainOwner.relationship === '配偶者')", () => {
      // 例: ログインユーザー = D (本人), 被保険者 M はログインユーザーから見て '配偶者'
      // => D は M の配偶者になるので許可されるべき
      const mainOwner = mk("M", "同居", "配偶者"); // M は login user から見て配偶者
      const driver = mk("D", undefined, "本人"); // driver は login user(=D)
      expect(isCoveredDriver("本人+配偶者", [driver], mainOwner)).toBe(true);
    });

    it("曖昧ケースは厳格に NG(誤判定を避ける)", () => {
      // 両方とも配偶者という値などで判定できない場合は false とする
      const mainOwner = mk("X", "別居", "配偶者");
      const driver = mk("Y", "別居", "配偶者"); // driver は login user から見て配偶者(別の人)
      expect(isCoveredDriver("本人+配偶者", [driver], mainOwner)).toBe(false);
    });
  });

  describe("driverScope = 本人+配偶者+同居の家族", () => {
    it("被保険者本人 OK", () => {
      const mainOwner = mk("ME", "同居", "本人");
      expect(isCoveredDriver("本人+配偶者+同居の家族", [mk("ME", "同居", "本人")], mainOwner)).toBe(true);
    });

    it("被保険者が同居する実親で、運転者がログインユーザー(本人) -> OK (ご指摘の例)", () => {
      // mainOwner: login user から見て '実親' かつ mainOwner.livingStatus === '同居'
      const mainOwner = mk("PARENT", "同居", "実親");
      // driver: login user(本人)。login user は mainOwner と同居している想定(mainOwner.livingStatus === '同居')
      const driver = mk("ME", undefined, "本人");
      expect(isCoveredDriver("本人+配偶者+同居の家族", [driver], mainOwner)).toBe(true);
    });

    it("同居フラグで同居家族と判定されるケース", () => {
      const mainOwner = mk("M", "同居", "本人");
      const sibling = mk("SIB", "同居", "兄弟");
      expect(isCoveredDriver("本人+配偶者+同居の家族", [sibling], mainOwner)).toBe(true);
    });

    it("別居家族は NG", () => {
      const mainOwner = mk("M", "同居", "本人");
      const distant = mk("D", "別居", "兄弟");
      expect(isCoveredDriver("本人+配偶者+同居の家族", [distant], mainOwner)).toBe(false);
    });

    it("配偶者(別居でも) -> 配偶者判定は許可", () => {
      const mainOwner = mk("ME", "別居", "本人");
      const spouse = mk("SP", "別居", "配偶者");
      expect(isCoveredDriver("本人+配偶者+同居の家族", [spouse], mainOwner)).toBe(true);
    });

    it("複数運転者で1人でも NG なら false", () => {
      const mainOwner = mk("ME", "同居", "本人");
      const ok = mk("A", "同居", "兄弟");
      const ng = mk("B", "別居", "兄弟");
      expect(isCoveredDriver("本人+配偶者+同居の家族", [ok, ng], mainOwner)).toBe(false);
    });
  });
});

実装上の補足(なぜこのやり方か)

  • 続柄を完全に「被保険者視点」に変換してラベルを当て直すと、家系のあらゆる組み合わせ(親→子→配偶者など)を網羅する膨大なロジックが必要になり、実務では過学習かつ誤判定の原因になります。
  • そこで「ログインユーザー視点の relationshiplivingStatus の組み合わせから、被保険者にとって 本人/配偶者/同居家族 になるかを保守的に推論」する実装にしました。

    • 重要なのは過大判定(誤って OK にする)を避けることなので、曖昧なケースは false にする方針を取っています。
  • ご要望があれば、より攻めた(=積極的に OK にする)ルールや、逆に家系変換表を作って正確に「被保険者基準の続柄」に変換する厳密版も作れます。

必要なら次を用意します:

  • 上の実装での Jest / Vitest 実行スナップショット(実行結果)
  • 続柄を完全に変換する「家系トランスレータ」版(より複雑)
  • 既存システムの具体データ(実際の relationship の使い方)に合わせてルール微調整

どれを優先しますか?

aa

src/
├── routes/
│   └── downloadZip.ts         # ルート定義(APIエンドポイント)
├── services/
│   └── zipService.ts          # ZIP作成ロジック
├── utils/
│   └── s3Client.ts            # S3クライアント初期化
├── types/
│   └── index.d.ts             # 型定義(必要であれば)
└── server.ts                  # サーバー起動エントリポイント

src/server.ts

import Fastify from 'fastify';
import downloadZipRoute from './routes/downloadZip';

const server = Fastify({ logger: true });

server.register(downloadZipRoute);

server.listen({ port: 3000 }, err => {
  if (err) {
    server.log.error(err);
    process.exit(1);
  }
  server.log.info('Server listening on http://localhost:3000');
});

src/routes/downloadZip.ts

import { FastifyPluginCallback } from 'fastify';
import { createZipStream } from '../services/zipService';

const downloadZipRoute: FastifyPluginCallback = (fastify, _, done) => {
  fastify.post('/download-zip', async (request, reply) => {
    const s3Keys = request.body as string[];

    if (!Array.isArray(s3Keys)) {
      reply.status(400).send({ error: 'Invalid request format' });
      return;
    }

    const zipStream = await createZipStream(s3Keys, fastify.log);

    reply
      .header('Content-Type', 'application/zip')
      .header('Content-Disposition', 'attachment; filename=files.zip')
      .send(zipStream);
  });

  done();
};

export default downloadZipRoute;

src/services/zipService.ts

import archiver from 'archiver';
import { PassThrough } from 'stream';
import { getObjectStream } from '../utils/s3Client';

export const createZipStream = async (keys: string[], logger: any): Promise<NodeJS.ReadableStream> => {
  const archive = archiver('zip', { zlib: { level: 9 } });
  const stream = new PassThrough();

  archive.on('error', (err) => {
    logger.error(err);
    stream.destroy(err);
  });

  archive.pipe(stream);

  for (const key of keys) {
    try {
      const s3Stream = await getObjectStream(key);
      archive.append(s3Stream, { name: key.split('/').pop() || 'file' });
    } catch (err) {
      logger.error(`Failed to get object: ${key}`, err);
    }
  }

  archive.finalize();
  return stream;
};

src/utils/s3Client.ts

const s3 = new S3Client({ region: 'ap-northeast-1' });
const BUCKET_NAME = 'your-s3-bucket-name';

export const getObjectStream = async (key: string): Promise<NodeJS.ReadableStream> => {
  const command = new GetObjectCommand({
    Bucket: BUCKET_NAME,
    Key: key,
  });

  const res = await s3.send(command);
  return res.Body as NodeJS.ReadableStream;
};

<template>
    <div class="overlay">
      <div class="content">
        <!-- コンテンツ -->
        <p v-for="i in 100" :key="i">スクロール可能なコンテンツ {{ i }}</p>
      </div>
    </div>
  </template>
  
  <script setup>
  import { onMounted, onUnmounted } from "vue";
  
  onMounted(() => {
    document.body.style.overflow = "hidden"; // body のスクロールを無効化
    document.documentElement.style.overflow = "hidden"; // html のスクロールも無効化
  });
  
  onUnmounted(() => {
    document.body.style.overflow = ""; // 元に戻す
    document.documentElement.style.overflow = ""; // 元に戻す
  });
  </script>
  
  <style scoped>
  .overlay {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(0, 0, 0, 0.5);
    overflow: hidden; /* 二重スクロール防止 */
  }
  
  .content {
    position: absolute;
    top: 0;
    left: 0;
    width: 78%;
    height:  78%;
    background: white;
    overflow-y: auto; /* ここだけスクロール可能 */
    padding: 20px;
    box-sizing: border-box;
  }
  </style>
  

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';

const isFixed = ref(false);
const isScrolledOut = ref(false);
const scrollThreshold = 300;

const handleScroll = () => {
  const scrollY = window.scrollY;
  isFixed.value = scrollY > scrollThreshold;
  isScrolledOut.value = scrollY > scrollThreshold; // 画像をスライドアウトさせる
};

onMounted(() => {
  window.addEventListener('scroll', handleScroll);
});

onUnmounted(() => {
  window.removeEventListener('scroll', handleScroll);
});
</script>

<template>
  <div class="wrap">
      <div :class="['contenta', { 'fixed': isFixed, 'visible': isFixed }]">
        <img src="/test/image1.svg" class="image1" :class="{ 'slide-out': isScrolledOut }" />
        <div class="overlay">テキスト</div>
        <img src="/test/image2.svg" class="image2" :class="{ 'slide-out': isScrolledOut }" />
    </div>
  </div>
</template>

<style scoped>
.wrap { 
  padding-top:300px;
  width:100%;
  height: 1200px;
  display: flex;
  flex-direction: column;
  margin: 0 auto;
}

.contenta {
    width: 100%;
  position: relative;
  transition: transform 0.5s ease-in-out, opacity 0.5s ease-in-out;

}

.fixed {
  position: fixed;
  top: 0;

  transform: translateY(-100%);
  width: 100%;
  z-index: 1000;
  height: 100px;
  background: rgba(160, 160, 160, 0.2);
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  opacity: 0;
}

.visible {
transform: translateY(0);
  opacity: 1;

}

/* 画像をスライドアウトするアニメーション */
.slide-out {

  transform: translateY(-100%);
  opacity: 0;
  transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out;
  transition-delay: 0s; /* すべて同時に動かす */


}

.image1,
.image2,
.overlay {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

.image1 {
  z-index: 1;
  width: 64px;
  height: 64px;
  left: 42px;

}

.overlay {
  z-index: 2;
  background: aquamarine;
  display: flex;
  align-items: center;
  justify-content: center;
  top: 20px;
  width: 155px;
  height: 50px;
}

.image2 {
  z-index: 3;
  width: 16px;
  height: 16px;
  top: 12px;
  left: 86px;

}
</style>

auth0

import { jwtVerify, createRemoteJWKSet } from "jose";

const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN;
const JWKS_URL = `https://${AUTH0_DOMAIN}/.well-known/jwks.json`;

const JWKS = createRemoteJWKSet(new URL(JWKS_URL));

export async function verifyJwt(token) {
  try {
    const { payload } = await jwtVerify(token, JWKS, {
      issuer: `https://${AUTH0_DOMAIN}/`,
      audience: process.env.AUTH0_AUDIENCE,
      algorithms: ["RS256"],
    });
    return payload; // デコードされたJWTのペイロードを返す
  } catch (error) {
    console.error("JWT verification failed:", error);
    throw new Error("Invalid token");
  }
}
import Fastify from "fastify";
import dotenv from "dotenv";
import authPlugin from "./auth.js";

dotenv.config();
const fastify = Fastify();

// JWT 認証プラグインを登録
fastify.register(authPlugin);

// 保護されたルート
fastify.get("/api/protected", { preHandler: [fastify.authenticate] }, async (request, reply) => {
  return { message: "This is a protected route", user: request.user };
});

// サーバー起動
const start = async () => {
  try {
    await fastify.listen({ port: 3000, host: "0.0.0.0" });
    console.log("Server running on port 3000");
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
};

start();

🔥 Fastify での認証のポイント ✅ プラグインとして認証を登録 Fastify の fastify.decorate() を使って authenticate をグローバル登録
ルートごとに preHandler: [fastify.authenticate] で適用できる

✅ 非同期(async/await)で処理
jwtVerify() を使うので、非同期で動作
request.user にデコード済みのJWT情報を保存

✅ パフォーマンス
createRemoteJWKSet() を使うことでAuth0の公開鍵をキャッシュ
fastify は express よりもパフォーマンスが高い

🎯 結論
Fastifyでも「jose」を使ってJWTの認証を簡単に実装できる
authenticate を Fastifyプラグイン化 しておくと、他のルートでも簡単に再利用可能
非同期 & 高速 なので、Fargateでの運用にも適している
この実装をベースに、認可(リソース制限)を追加するのもOK!
何か追加したい要件があれば、気軽に聞いてください!

画面側

<template>
  <div>
    <button v-if="!isAuthenticated" @click="login">Login</button>
    <button v-if="isAuthenticated" @click="logout">Logout</button>

    <div v-if="isAuthenticated">
      <p>Welcome, {{ user.name }}!</p>
      <p>Email: {{ user.email }}</p>
      <p>ID Token: {{ idToken }}</p>
      <p>Access Token: {{ accessToken }}</p>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import { useAuth0 } from '@auth0/auth0-vue';

const { loginWithRedirect, logout, user, isAuthenticated, getAccessTokenSilently, getIdTokenSilently } = useAuth0();
const idToken = ref(null);
const accessToken = ref(null);

const login = async () => {
  try {
    await loginWithRedirect();
  } catch (error) {
    console.error(error);
  }
};

const logoutUser = () => {
  logout({ returnTo: window.location.origin });
};

const fetchTokens = async () => {
  if (isAuthenticated.value) {
    try {
      idToken.value = await getIdTokenSilently();
      accessToken.value = await getAccessTokenSilently();
    } catch (error) {
      console.error('Error fetching tokens:', error);
    }
  }
};

onMounted(() => {
  if (isAuthenticated.value) {
    fetchTokens();
  }
});
</script>

export default {
  head: {
    // 既存のmetaやtitleの設定
    title: 'Your Page Title',
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { hid: 'description', name: 'description', content: 'Your page description' }
    ],
    // JavaScriptを追加
    script: [
      {
        src: 'https://example.com/your-script.js', // 外部スクリプトのURL
        async: true
      },
      {
        innerHTML: `
          // インラインでJavaScriptを追加
          console.log('Hello, Nuxt!');
        `,
        type: 'text/javascript'
      }
    ],
    __dangerouslyDisableSanitizers: ['script'] // インラインスクリプトを使用する場合、サニタイズを無効にする必要がある
  }
}