Sync Type Finder 制作メモ

スポンサーリンク

もちろん。いま作ったファイルは、大きく分けると「AWS構成」「バックエンド」「フロントエンド」「設計書」「テスト」「ローカル確認用」です。

全体説明
README.md
プロジェクトの説明書です。アプリの概要、AWS構成、ローカルでの起動方法、テスト方法、SAMでのデプロイ手順を書いています。

template.yaml
AWS SAM用の構成ファイルです。
ここで API GatewayLambdaDynamoDB を定義しています。AWSにデプロイすると、このファイルをもとに必要なリソースが作られます。

package.json
Node.jsプロジェクトの設定ファイルです。
npm testnpm start で何を実行するかを定義しています。今回は外部ライブラリを増やさない最小構成です。

.gitignore
Gitで管理しないファイルを指定するものです。
node_modules/.aws-sam/ など、生成物や環境依存ファイルを除外しています。

バックエンド
src/diagnosis.mjs
Lambdaで動くメイン処理です。

主な役割は以下です。

  • 回答から5種類のオリジナルタイプを判定する
  • 名前、タイプ、診断日時をDynamoDBに保存する
  • 保存済みの人の中から相性のよいタイプの人を探す
  • POST /diagnosesGET /diagnoses のAPI処理を行う

つまり、アプリの「診断ロジック」と「保存処理」の中心です。

フロントエンド
frontend/index.html
画面の骨組みです。
名前入力欄、質問表示エリア、診断ボタン、結果表示エリアなどを置いています。

frontend/styles.css
画面デザイン用のCSSです。
レイアウト、色、ボタン、質問カード、スマホ対応などを定義しています。

frontend/app.js
画面側の動きを担当します。

主な役割は以下です。

  • 10問の質問を画面に表示する
  • 回答数をカウントする
  • 診断ボタンを押したときに回答を集める
  • APIに送信する
  • ローカル確認時は localStorage に仮保存する
  • 結果と相性のよい人を画面に表示する

AWSに接続する場合は、この中の API_BASE_URL にAPI GatewayのURLを入れます。

ローカル確認用
server.cjs
フロント画面をローカルで表示するための簡易Webサーバーです。
node server.cjs 4173 を実行すると、frontend/index.html をブラウザで見られるようになります。

テスト
tests/diagnosis.test.mjs
バックエンドの診断ロジック用テストです。

確認している内容は主に2つです。

  • 回答から正しいタイプが判定されるか
  • 自分自身を除外して、相性のよいタイプの人だけが表示されるか

設計書
docs/requirements.md
要件定義メモです。
このアプリで「やること」「やらないこと」、機能要件、非機能要件、V字開発での成果物例を書いています。

docs/basic-design.md
基本設計メモです。
画面設計、API設計、DynamoDB設計、タイプ一覧、相性ルール、テスト方針を書いています。

ざっくり言うと、frontend/ が画面、src/ がLambda、template.yaml がAWS構成、docs/ が研修用の設計資料、tests/ がロジック確認用です。

frontrnd
index.html

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Sync Type Finder</title>
    <link rel="stylesheet" href="./styles.css">
  </head>
  <body>
    <main class="app-shell">
      <section class="diagnosis-panel" aria-labelledby="app-title">
        <div class="title-row">
          <div>
            <p class="eyebrow">同期タイプ診断</p>
            <h1 id="app-title">Sync Type Finder</h1>
          </div>
          <div class="progress" aria-live="polite">
            <span id="answered-count">0</span>/<span id="question-count">10</span>
          </div>
        </div>

        <form id="diagnosis-form">
          <label class="name-field">
            <span>名前</span>
            <input id="name-input" name="name" maxlength="30" autocomplete="name" required placeholder="例: 佐藤">
          </label>

          <div id="questions" class="questions"></div>

          <button class="submit-button" type="submit">診断する</button>
          <p id="form-message" class="form-message" role="status"></p>
        </form>
      </section>

      <section id="result-panel" class="result-panel" hidden aria-live="polite">
        <p class="eyebrow">診断結果</p>
        <h2 id="result-title"></h2>
        <p id="result-description" class="result-description"></p>
        <div class="match-box">
          <p class="match-label">相性のよい同期</p>
          <p id="match-message" class="match-message"></p>
        </div>
        <button id="retry-button" class="secondary-button" type="button">もう一度診断する</button>
      </section>
    </main>
    <script src="./app.js" type="module"></script>
  </body>
</html>

styles.css

:root {
  color-scheme: light;
  --ink: #17202a;
  --muted: #627184;
  --line: #d7dde5;
  --panel: #ffffff;
  --page: #f5f7f8;
  --accent: #256f7a;
  --accent-strong: #174f58;
  --warm: #c86b3c;
  --focus: #f0b429;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  font-family: "Segoe UI", system-ui, sans-serif;
  background: var(--page);
  color: var(--ink);
}

button,
input {
  font: inherit;
}

.app-shell {
  width: min(1120px, calc(100% - 32px));
  margin: 0 auto;
  padding: 32px 0;
  display: grid;
  grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr);
  gap: 24px;
  align-items: start;
}

.diagnosis-panel,
.result-panel {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 24px;
  box-shadow: 0 12px 30px rgba(23, 32, 42, 0.08);
}

.title-row {
  display: flex;
  align-items: start;
  justify-content: space-between;
  gap: 16px;
  border-bottom: 1px solid var(--line);
  padding-bottom: 18px;
  margin-bottom: 20px;
}

.eyebrow {
  color: var(--warm);
  font-size: 0.86rem;
  font-weight: 700;
  margin: 0 0 4px;
}

h1,
h2 {
  margin: 0;
  letter-spacing: 0;
}

h1 {
  font-size: clamp(1.8rem, 2.5rem, 2.5rem);
}

h2 {
  font-size: 1.75rem;
}

.progress {
  min-width: 72px;
  text-align: center;
  color: var(--accent-strong);
  font-weight: 800;
  border: 1px solid #b8d4d8;
  background: #eef8f9;
  border-radius: 999px;
  padding: 8px 12px;
}

.name-field {
  display: grid;
  gap: 8px;
  margin-bottom: 22px;
  font-weight: 700;
}

.name-field input {
  width: 100%;
  min-height: 44px;
  border: 1px solid var(--line);
  border-radius: 6px;
  padding: 10px 12px;
}

.name-field input:focus,
.question-option input:focus-visible + span,
button:focus-visible {
  outline: 3px solid var(--focus);
  outline-offset: 2px;
}

.questions {
  display: grid;
  gap: 16px;
}

.question-card {
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 16px;
}

.question-title {
  margin: 0 0 12px;
  font-weight: 800;
}

.options {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
}

.question-option input {
  position: absolute;
  opacity: 0;
}

.question-option span {
  display: block;
  min-height: 48px;
  border: 1px solid var(--line);
  border-radius: 6px;
  padding: 12px;
  cursor: pointer;
  background: #fbfcfd;
}

.question-option input:checked + span {
  border-color: var(--accent);
  background: #e8f4f5;
  color: var(--accent-strong);
  font-weight: 800;
}

.submit-button,
.secondary-button {
  min-height: 46px;
  border: 0;
  border-radius: 6px;
  padding: 0 18px;
  cursor: pointer;
  font-weight: 800;
}

.submit-button {
  width: 100%;
  margin-top: 20px;
  background: var(--accent);
  color: #fff;
}

.submit-button:hover {
  background: var(--accent-strong);
}

.secondary-button {
  background: #ecf0f3;
  color: var(--ink);
}

.form-message {
  min-height: 24px;
  color: #9b2c2c;
  font-weight: 700;
}

.result-panel {
  position: sticky;
  top: 24px;
}

.result-panel[hidden] {
  display: none;
}

.result-description {
  color: var(--muted);
  line-height: 1.7;
}

.match-box {
  border-top: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
  padding: 18px 0;
  margin: 18px 0;
}

.match-label {
  margin: 0 0 6px;
  color: var(--muted);
  font-size: 0.9rem;
  font-weight: 700;
}

.match-message {
  margin: 0;
  font-size: 1.2rem;
  font-weight: 800;
  line-height: 1.5;
}

@media (max-width: 860px) {
  .app-shell {
    grid-template-columns: 1fr;
    width: min(100% - 20px, 680px);
    padding: 16px 0;
  }

  .diagnosis-panel,
  .result-panel {
    padding: 18px;
  }

  .result-panel {
    position: static;
  }
}

@media (max-width: 560px) {
  .title-row,
  .options {
    grid-template-columns: 1fr;
  }

  .title-row {
    display: grid;
  }

  .progress {
    width: max-content;
  }
}


app.js

const API_BASE_URL = "";

const typeDescriptions = {
  SPARK: "新しいことに素早く反応し、場を前に進めるタイプです。",
  HARBOR: "周囲をよく見て、安心して話せる空気を作るタイプです。",
  ROOT: "物事の土台を整え、着実に形にしていくタイプです。",
  CRAFT: "細部を観察し、品質や完成度を高めるタイプです。",
  BLOOM: "人の良さや可能性を見つけ、関係を広げるタイプです。",
};

const questions = [
  {
    text: "新しい課題が始まったとき、最初にやりたいことは?",
    options: [
      { text: "まず動くものを作る", scores: { SPARK: 2, CRAFT: 1 } },
      { text: "目的と前提を整理する", scores: { ROOT: 2, HARBOR: 1 } },
    ],
  },
  {
    text: "チームで話し合うときに意識しやすいことは?",
    options: [
      { text: "意見を出して流れを作る", scores: { SPARK: 2, BLOOM: 1 } },
      { text: "全員が話しやすい状態にする", scores: { HARBOR: 2, BLOOM: 1 } },
    ],
  },
  {
    text: "作業で気持ちよく感じる瞬間は?",
    options: [
      { text: "チェックリストが順番に埋まる", scores: { ROOT: 2, CRAFT: 1 } },
      { text: "小さな改善で使いやすくなる", scores: { CRAFT: 2, HARBOR: 1 } },
    ],
  },
  {
    text: "困っている同期がいたらどうする?",
    options: [
      { text: "すぐ声をかけて一緒に考える", scores: { BLOOM: 2, HARBOR: 1 } },
      { text: "原因を切り分けて道筋を示す", scores: { ROOT: 2, CRAFT: 1 } },
    ],
  },
  {
    text: "発表前に気になることは?",
    options: [
      { text: "聞き手に伝わる熱量があるか", scores: { SPARK: 2, BLOOM: 1 } },
      { text: "説明の抜け漏れがないか", scores: { CRAFT: 2, ROOT: 1 } },
    ],
  },
  {
    text: "予定より遅れたときに取りやすい行動は?",
    options: [
      { text: "優先順位を決め直す", scores: { ROOT: 2, SPARK: 1 } },
      { text: "周囲に状況を共有する", scores: { HARBOR: 2, BLOOM: 1 } },
    ],
  },
  {
    text: "レビューで見つけたいものは?",
    options: [
      { text: "ユーザーが迷いそうな点", scores: { HARBOR: 2, BLOOM: 1 } },
      { text: "仕様と実装のずれ", scores: { CRAFT: 2, ROOT: 1 } },
    ],
  },
  {
    text: "アイデア出しで得意な役割は?",
    options: [
      { text: "たくさん案を出す", scores: { SPARK: 2, BLOOM: 1 } },
      { text: "案を整理して選びやすくする", scores: { ROOT: 2, HARBOR: 1 } },
    ],
  },
  {
    text: "完成に近づいたとき、最後に見たいところは?",
    options: [
      { text: "体験として自然に使えるか", scores: { HARBOR: 2, CRAFT: 1 } },
      { text: "細かい表示や文言が整っているか", scores: { CRAFT: 2, ROOT: 1 } },
    ],
  },
  {
    text: "同期との関わりで大事にしたいことは?",
    options: [
      { text: "互いの良さを見つける", scores: { BLOOM: 2, HARBOR: 1 } },
      { text: "一緒に成果を出す", scores: { SPARK: 1, ROOT: 2 } },
    ],
  },
];

const form = document.querySelector("#diagnosis-form");
const questionsRoot = document.querySelector("#questions");
const answeredCount = document.querySelector("#answered-count");
const questionCount = document.querySelector("#question-count");
const formMessage = document.querySelector("#form-message");
const resultPanel = document.querySelector("#result-panel");
const resultTitle = document.querySelector("#result-title");
const resultDescription = document.querySelector("#result-description");
const matchMessage = document.querySelector("#match-message");
const retryButton = document.querySelector("#retry-button");

questionCount.textContent = String(questions.length);
renderQuestions();
updateAnsweredCount();

form.addEventListener("change", updateAnsweredCount);
form.addEventListener("submit", handleSubmit);
retryButton.addEventListener("click", () => {
  resultPanel.hidden = true;
  form.reset();
  updateAnsweredCount();
  document.querySelector("#name-input").focus();
});

function renderQuestions() {
  questionsRoot.innerHTML = questions.map((question, questionIndex) => {
    const options = question.options.map((option, optionIndex) => {
      const id = `q${questionIndex}-${optionIndex}`;
      return `
        <label class="question-option" for="${id}">
          <input id="${id}" type="radio" name="q${questionIndex}" value="${optionIndex}" required>
          <span>${escapeHtml(option.text)}</span>
        </label>
      `;
    }).join("");

    return `
      <fieldset class="question-card">
        <legend class="question-title">${questionIndex + 1}. ${escapeHtml(question.text)}</legend>
        <div class="options">${options}</div>
      </fieldset>
    `;
  }).join("");
}

async function handleSubmit(event) {
  event.preventDefault();
  formMessage.textContent = "";

  const name = document.querySelector("#name-input").value.trim();
  const answers = collectAnswers();

  if (answers.length !== questions.length) {
    formMessage.textContent = "すべての質問に回答してください。";
    return;
  }

  const submitButton = form.querySelector("button[type='submit']");
  submitButton.disabled = true;
  submitButton.textContent = "診断中...";

  try {
    const payload = await submitDiagnosis({ name, answers });
    showResult(payload);
  } catch (error) {
    formMessage.textContent = error.message;
  } finally {
    submitButton.disabled = false;
    submitButton.textContent = "診断する";
  }
}

function collectAnswers() {
  return questions.flatMap((question, questionIndex) => {
    const checked = form.querySelector(`input[name='q${questionIndex}']:checked`);
    if (!checked) return [];
    return [question.options[Number(checked.value)].scores];
  });
}

async function submitDiagnosis(payload) {
  if (!API_BASE_URL) {
    return submitDiagnosisLocally(payload);
  }

  const response = await fetch(`${API_BASE_URL}/diagnoses`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(payload),
  });

  if (!response.ok) {
    throw new Error("診断結果を保存できませんでした。");
  }

  return response.json();
}

function submitDiagnosisLocally(payload) {
  const result = calculateType(payload.answers);
  const records = JSON.parse(localStorage.getItem("sync-type-records") ?? "[]");
  const record = {
    name: payload.name,
    type: result.type,
    label: result.label,
    createdAt: new Date().toISOString(),
  };
  records.push(record);
  localStorage.setItem("sync-type-records", JSON.stringify(records));

  return {
    name: payload.name,
    result,
    matches: findLocalMatches(records, payload.name, result.type),
  };
}

function calculateType(answers) {
  const scores = { SPARK: 0, HARBOR: 0, ROOT: 0, CRAFT: 0, BLOOM: 0 };
  for (const answer of answers) {
    for (const [type, value] of Object.entries(answer)) {
      scores[type] += value;
    }
  }
  const [type] = Object.entries(scores).sort((a, b) => b[1] - a[1])[0];
  return {
    type,
    label: typeLabels[type],
    compatibleTypes: compatibility[type],
    scores,
  };
}

function findLocalMatches(records, ownName, ownType) {
  return records
    .filter((record) => compatibility[ownType].includes(record.type))
    .filter((record) => record.name.trim().toLowerCase() !== ownName.trim().toLowerCase())
    .slice(0, 5);
}

function showResult(payload) {
  resultTitle.textContent = `${payload.name}さんは${payload.result.label}タイプ`;
  resultDescription.textContent = typeDescriptions[payload.result.type];

  if (payload.matches.length > 0) {
    const names = payload.matches.map((match) => `${match.name}さん`).join("、");
    matchMessage.textContent = `あなたは${names}と相性がいい!`;
  } else {
    matchMessage.textContent = "まだ相性のよい同期が登録されていません。次の人の診断を待ちましょう。";
  }

  resultPanel.hidden = false;
  resultPanel.scrollIntoView({ behavior: "smooth", block: "start" });
}

function updateAnsweredCount() {
  const answered = questions.filter((_, index) => form.querySelector(`input[name='q${index}']:checked`)).length;
  answeredCount.textContent = String(answered);
}

function escapeHtml(value) {
  return String(value)
    .replaceAll("&", "&")
    .replaceAll("<", "<")
    .replaceAll(">", ">")
    .replaceAll('"', """)
    .replaceAll("'", "'");
}

const typeLabels = {
  SPARK: "スパーク",
  HARBOR: "ハーバー",
  ROOT: "ルート",
  CRAFT: "クラフト",
  BLOOM: "ブルーム",
};

const compatibility = {
  SPARK: ["HARBOR", "ROOT"],
  HARBOR: ["SPARK", "CRAFT"],
  ROOT: ["SPARK", "BLOOM"],
  CRAFT: ["HARBOR", "BLOOM"],
  BLOOM: ["ROOT", "CRAFT"],
};

src
diagnosis.mjs

import crypto from "node:crypto";

const TYPE_COMPATIBILITY = {
  SPARK: ["HARBOR", "ROOT"],
  HARBOR: ["SPARK", "CRAFT"],
  ROOT: ["SPARK", "BLOOM"],
  CRAFT: ["HARBOR", "BLOOM"],
  BLOOM: ["ROOT", "CRAFT"],
};

const TYPE_LABELS = {
  SPARK: "スパーク",
  HARBOR: "ハーバー",
  ROOT: "ルート",
  CRAFT: "クラフト",
  BLOOM: "ブルーム",
};

const responseHeaders = {
  "content-type": "application/json; charset=utf-8",
  "access-control-allow-origin": "*",
};

export function calculateType(answers) {
  if (!Array.isArray(answers) || answers.length === 0) {
    throw new Error("answers must be a non-empty array");
  }

  const scores = {
    SPARK: 0,
    HARBOR: 0,
    ROOT: 0,
    CRAFT: 0,
    BLOOM: 0,
  };

  for (const answer of answers) {
    if (!answer || typeof answer !== "object") {
      throw new Error("answer must be an object");
    }

    for (const [type, value] of Object.entries(answer)) {
      if (!(type in scores) || !Number.isInteger(value)) {
        throw new Error("answer contains an invalid score");
      }
      scores[type] += value;
    }
  }

  const sorted = Object.entries(scores).sort((a, b) => {
    if (b[1] !== a[1]) return b[1] - a[1];
    return Object.keys(scores).indexOf(a[0]) - Object.keys(scores).indexOf(b[0]);
  });

  const type = sorted[0][0];
  return {
    type,
    label: TYPE_LABELS[type],
    compatibleTypes: TYPE_COMPATIBILITY[type],
    scores,
  };
}

export function findMatches(records, ownName, ownType) {
  const compatibleTypes = TYPE_COMPATIBILITY[ownType] ?? [];
  return records
    .filter((record) => compatibleTypes.includes(record.type))
    .filter((record) => normalizeName(record.name) !== normalizeName(ownName))
    .sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)))
    .slice(0, 5)
    .map((record) => ({
      name: record.name,
      type: record.type,
      label: TYPE_LABELS[record.type] ?? record.type,
    }));
}

export async function handler(event) {
  try {
    if (event.requestContext?.http?.method === "POST") {
      return await handleCreate(event);
    }

    if (event.requestContext?.http?.method === "GET") {
      return await handleList(event);
    }

    return json(405, { message: "Method not allowed" });
  } catch (error) {
    console.error(error);
    if (error instanceof SyntaxError || error.message.includes("must be")) {
      return json(400, { message: error.message });
    }
    return json(500, { message: "Internal server error" });
  }
}

async function handleCreate(event) {
  const body = JSON.parse(event.body ?? "{}");
  const name = sanitizeName(body.name);
  const result = calculateType(body.answers);
  const now = new Date().toISOString();
  const id = crypto.randomUUID();

  const item = {
    id,
    name,
    type: result.type,
    createdAt: now,
  };

  await putItem(item);
  const allRecords = await scanRecords();

  return json(201, {
    id,
    name,
    result,
    matches: findMatches(allRecords, name, result.type),
  });
}

async function handleList(event) {
  const type = event.queryStringParameters?.type;
  const records = await scanRecords();
  const filteredRecords = type
    ? records.filter((record) => record.type === type)
    : records;

  return json(200, {
    items: filteredRecords.map((record) => ({
      name: record.name,
      type: record.type,
      label: TYPE_LABELS[record.type] ?? record.type,
      createdAt: record.createdAt,
    })),
  });
}

function sanitizeName(value) {
  const name = String(value ?? "").trim();
  if (name.length < 1 || name.length > 30) {
    throw new Error("name must be 1 to 30 characters");
  }
  return name;
}

function normalizeName(name) {
  return String(name ?? "").trim().toLowerCase();
}

function json(statusCode, body) {
  return {
    statusCode,
    headers: responseHeaders,
    body: JSON.stringify(body),
  };
}

async function putItem(item) {
  const tableName = process.env.TABLE_NAME;
  if (!tableName) {
    throw new Error("TABLE_NAME is not set");
  }

  const { DynamoDBClient } = await import("@aws-sdk/client-dynamodb");
  const { PutItemCommand } = await import("@aws-sdk/client-dynamodb");
  const client = new DynamoDBClient({});

  await client.send(new PutItemCommand({
    TableName: tableName,
    Item: {
      id: { S: item.id },
      name: { S: item.name },
      type: { S: item.type },
      createdAt: { S: item.createdAt },
    },
  }));
}

async function scanRecords() {
  const tableName = process.env.TABLE_NAME;
  if (!tableName) {
    throw new Error("TABLE_NAME is not set");
  }

  const { DynamoDBClient } = await import("@aws-sdk/client-dynamodb");
  const { ScanCommand } = await import("@aws-sdk/client-dynamodb");
  const client = new DynamoDBClient({});
  const result = await client.send(new ScanCommand({ TableName: tableName }));

  return (result.Items ?? []).map((item) => ({
    id: item.id?.S,
    name: item.name?.S,
    type: item.type?.S,
    createdAt: item.createdAt?.S,
  }));
}

素の
server.cjs

const http = require("node:http");
const fs = require("node:fs");
const path = require("node:path");

const port = Number(process.argv[2] ?? 4173);
const root = path.join(__dirname, "frontend");

const mimeTypes = {
  ".html": "text/html; charset=utf-8",
  ".css": "text/css; charset=utf-8",
  ".js": "text/javascript; charset=utf-8",
};

const server = http.createServer((request, response) => {
  const urlPath = new URL(request.url, `http://${request.headers.host}`).pathname;
  const safePath = path.normalize(urlPath).replace(/^(\.\.[/\\])+/, "");
  const filePath = path.join(root, safePath === "/" ? "index.html" : safePath);

  if (!filePath.startsWith(root)) {
    response.writeHead(403);
    response.end("Forbidden");
    return;
  }

  fs.readFile(filePath, (error, content) => {
    if (error) {
      response.writeHead(404);
      response.end("Not found");
      return;
    }

    response.writeHead(200, {
      "content-type": mimeTypes[path.extname(filePath)] ?? "application/octet-stream",
    });
    response.end(content);
  });
});

server.listen(port, "127.0.0.1", () => {
  console.log(`Server running at http://127.0.0.1:${port}/index.html`);
});

template.yaml

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Minimal MBTI-like diagnosis app for new engineer training.

Globals:
  Function:
    Runtime: nodejs20.x
    Timeout: 10
    MemorySize: 128
    Architectures:
      - x86_64

Resources:
  DiagnosisTable:
    Type: AWS::DynamoDB::Table
    Properties:
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: id
          AttributeType: S
      KeySchema:
        - AttributeName: id
          KeyType: HASH

  DiagnosisApi:
    Type: AWS::Serverless::HttpApi
    Properties:
      CorsConfiguration:
        AllowOrigins:
          - "*"
        AllowMethods:
          - GET
          - POST
          - OPTIONS
        AllowHeaders:
          - content-type

  DiagnosisFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: diagnosis.handler
      Environment:
        Variables:
          TABLE_NAME: !Ref DiagnosisTable
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref DiagnosisTable
      Events:
        SubmitDiagnosis:
          Type: HttpApi
          Properties:
            ApiId: !Ref DiagnosisApi
            Path: /diagnoses
            Method: POST
        ListByType:
          Type: HttpApi
          Properties:
            ApiId: !Ref DiagnosisApi
            Path: /diagnoses
            Method: GET

Outputs:
  ApiUrl:
    Description: API Gateway endpoint URL
    Value: !Sub "https://${DiagnosisApi}.execute-api.${AWS::Region}.amazonaws.com"

tests/diagnosis.test.mjs

import test from "node:test";
import assert from "node:assert/strict";
import { calculateType, findMatches } from "../src/diagnosis.mjs";

test("calculateType returns the strongest original type", () => {
  const result = calculateType([
    { SPARK: 2, CRAFT: 1 },
    { SPARK: 2, BLOOM: 1 },
    { ROOT: 2, SPARK: 1 },
  ]);

  assert.equal(result.type, "SPARK");
  assert.equal(result.label, "スパーク");
  assert.deepEqual(result.compatibleTypes, ["HARBOR", "ROOT"]);
});

test("findMatches excludes self and returns compatible people", () => {
  const matches = findMatches([
    { name: "佐藤", type: "SPARK", createdAt: "2026-06-01T00:00:00.000Z" },
    { name: "田中", type: "HARBOR", createdAt: "2026-06-01T00:00:01.000Z" },
    { name: "鈴木", type: "ROOT", createdAt: "2026-06-01T00:00:02.000Z" },
    { name: "山田", type: "BLOOM", createdAt: "2026-06-01T00:00:03.000Z" },
  ], "佐藤", "SPARK");

  assert.deepEqual(matches.map((match) => match.name), ["田中", "鈴木"]);
});

package.json

{
  "name": "sync-type-finder",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "node --test",
    "start": "node server.cjs 4173"
  }
}

basic-design.md

# 基本設計メモ

## 画面

### 診断画面

- 名前入力欄
- 10問の二択質問
- 回答数表示
- 診断ボタン

### 結果画面

- 診断者名
- 診断タイプ
- タイプ説明
- 相性のよい登録者名

## API

### POST /diagnoses

診断結果を保存し、相性候補を返す。

Request:

```json
{
  "name": "佐藤",
  "answers": [
    { "SPARK": 2, "CRAFT": 1 }
  ]
}
```

Response:

```json
{
  "id": "uuid",
  "name": "佐藤",
  "result": {
    "type": "SPARK",
    "label": "スパーク",
    "compatibleTypes": ["HARBOR", "ROOT"],
    "scores": {
      "SPARK": 10,
      "HARBOR": 2,
      "ROOT": 4,
      "CRAFT": 3,
      "BLOOM": 1
    }
  },
  "matches": [
    { "name": "田中", "type": "HARBOR", "label": "ハーバー" }
  ]
}
```

### GET /diagnoses

保存済み診断者を取得する。動作確認や結合テスト用。

## DynamoDB

Table: `DiagnosisTable`

| 属性 | 型 | 内容 |
| --- | --- | --- |
| id | String | UUID、パーティションキー |
| name | String | 診断者名 |
| type | String | 診断タイプ |
| createdAt | String | ISO8601形式の診断日時 |

30人程度のため、相性候補取得はScanで実装する。件数が増えるサービスではGSIやページングを検討する。

## タイプ

| コード | 表示名 | 特徴 |
| --- | --- | --- |
| SPARK | スパーク | 新しいことに素早く動く |
| HARBOR | ハーバー | 場の安心感を作る |
| ROOT | ルート | 土台を整えて進める |
| CRAFT | クラフト | 品質や細部を高める |
| BLOOM | ブルーム | 人の良さを見つけ広げる |

## 相性ルール

| 自分 | 相性のよいタイプ |
| --- | --- |
| SPARK | HARBOR, ROOT |
| HARBOR | SPARK, CRAFT |
| ROOT | SPARK, BLOOM |
| CRAFT | HARBOR, BLOOM |
| BLOOM | ROOT, CRAFT |

## テスト方針

- 単体テスト: タイプ判定、相性候補抽出
- 結合テスト: POST /diagnosesで保存後に相性候補が返ること
- システムテスト: 30人分の名前で登録して画面表示が崩れないこと
# 要件定義メモ

## 目的

新人研修のチーム開発として、AWSの最小構成を使ったWebアプリケーションをV字開発工程に沿って作成する。対象者は社内同期30人程度とし、診断後に同期同士の相性を表示する。

## スコープ

### 含める

- 名前入力
- 10問の二択診断
- 5種類のオリジナルタイプ判定
- 診断結果の保存
- 診断者本人と相性のよいタイプの登録者表示

### 含めない

- ログイン
- 管理画面
- 診断結果の編集・削除
- 複雑なランキング
- メール通知
- MBTIという名称や既存16タイプの使用

## 機能要件

| ID | 要件 | 優先度 |
| --- | --- | --- |
| F-01 | 利用者は名前を入力できる | 必須 |
| F-02 | 利用者は診断質問に回答できる | 必須 |
| F-03 | システムは回答から5種類のタイプを判定できる | 必須 |
| F-04 | システムは名前、タイプ、診断日時を保存できる | 必須 |
| F-05 | システムは相性のよいタイプの登録者名を表示できる | 必須 |

## 非機能要件

| ID | 要件 | 方針 |
| --- | --- | --- |
| NF-01 | 利用規模 | 同期30人程度を想定 |
| NF-02 | 可用性 | 研修発表時に動作することを重視 |
| NF-03 | セキュリティ | 個人情報は名前のみ、公開範囲は研修内に限定 |
| NF-04 | 保守性 | 未経験者が読める単純な構成にする |

## V字開発での成果物例

| 工程 | 成果物 |
| --- | --- |
| 要件定義 | 本書、画面イメージ、機能一覧 |
| 基本設計 | 画面設計、API設計、DB設計 |
| 詳細設計 | 判定ロジック、バリデーション、エラー処理 |
| 実装 | フロント、Lambda、SAMテンプレート |
| 単体テスト | 判定ロジック、相性抽出 |
| 結合テスト | フロントからAPI経由で保存・表示 |
| システムテスト | 30人利用想定の手動確認 |

Readme

# Sync Type Finder

社内同期向けの、MBTI風オリジナル診断Webアプリケーションです。新人研修の11日間開発を想定し、AWSの利用要素を Lambda / DynamoDB / API Gateway に絞った最小構成にしています。

## 機能

- 名前を入力して10問の診断に回答する
- 5種類のオリジナルタイプから診断結果を表示する
- 診断結果をDynamoDBに保存する
- 保存済みの診断者から、相性のよいタイプの人を表示する

## AWS構成

- API Gateway: HTTP APIとしてフロントからLambdaを呼び出す
- Lambda: 診断結果の保存と相性候補の取得
- DynamoDB: 診断者の名前、タイプ、作成日時を保存

## ディレクトリ

```text
.
├── docs/
│   ├── requirements.md
│   └── basic-design.md
├── frontend/
│   ├── index.html
│   ├── styles.css
│   └── app.js
├── src/
│   └── diagnosis.mjs
├── tests/
│   └── diagnosis.test.mjs
├── template.yaml
└── server.cjs
```

## ローカルで画面確認

```powershell
node server.cjs 4173
```

ブラウザで `http://127.0.0.1:4173/index.html` を開きます。

ローカル表示ではAPI未設定のため、ブラウザ内のモック保存で結果画面まで確認できます。AWSデプロイ後は `frontend/app.js` の `API_BASE_URL` にAPI GatewayのURLを設定してください。

## テスト

```powershell
node --test
```

## デプロイの目安

AWS SAM CLIが使える環境で以下を実行します。

```powershell
sam build
sam deploy --guided
```

デプロイ後に出力される `ApiUrl` を `frontend/app.js` の `API_BASE_URL` に設定し、静的ファイルを任意の配布先に置きます。今回の課題条件ではAWS最小要素を Lambda / DynamoDB / API Gateway に限定するため、S3/CloudFrontは必須にしていません。

設計書

スポンサーリンク

要件定義書

要件定義書

1. 文書情報

項目内容
文書名Sync Type Finder 要件定義書
対象システム社内同期向けオリジナルタイプ診断Webアプリケーション
想定工程新人研修におけるV字開発工程
開発期間11日間
開発体制5名、全員未経験者を想定
想定利用者数最低30名程度
使用AWSサービスLambda、DynamoDB、API Gateway

2. 背景

新人研修のチーム開発課題として、AWSを最低限利用したWebアプリケーションを作成する。
題材はMBTI診断サイトに近い体験とするが、既存のMBTI名称、16タイプ、設問体系は使用せず、研修用のオリジナルタイプ診断として実装する。

診断後、利用者の名前と診断結果を保存し、保存済みの同期の中から相性のよいタイプの人を表示する。
利用者は社内同期を想定し、実利用規模は30人程度とする。

3. 目的

本システムの目的は以下である。

ID目的
P-01利用者が短時間でオリジナルタイプ診断を完了できること
P-02診断結果と名前を保存し、同期同士の相性表示に利用できること
P-03Lambda、DynamoDB、API Gatewayを使った基本的なWebアプリ構成を学習できること
P-04V字開発工程に沿って、要件定義、設計、実装、テストを一通り経験できること

4. システム概要

利用者はWeb画面から名前を入力し、10問の二択質問に回答する。
システムは回答内容を集計し、5種類のオリジナルタイプのうち最も点数が高いタイプを診断結果として表示する。
診断結果はAPI Gateway経由でLambdaに送信され、DynamoDBへ保存される。
保存済みの診断結果から、利用者のタイプと相性がよいタイプの人を抽出し、結果画面に表示する。

5. スコープ

5.1 スコープ内

ID内容
IN-01名前入力
IN-0210問の二択診断
IN-035種類のオリジナルタイプ判定
IN-04診断結果画面の表示
IN-05診断者名、診断タイプ、診断日時の保存
IN-06保存済み診断者から相性のよい人を表示
IN-07診断ロジックの単体テスト
IN-08API経由で保存、取得できることの結合確認

5.2 スコープ外

ID内容理由
OUT-01ログイン、認証11日間、未経験5名では設計と実装負荷が高い
OUT-02管理画面必須価値に直結しない
OUT-03診断結果の編集、削除利用規模が小さく、初期要件としては不要
OUT-04ランキング機能仕様が広がりやすいため除外
OUT-05メール、Slack通知AWS外部連携が増え、研修範囲を超えやすい
OUT-06S3、CloudFrontによる静的ホスティングAWS最低限要素をLambda、DynamoDB、API Gatewayに絞る
OUT-07既存MBTI名称、16タイプの使用商標、内容の誤解、設問設計の複雑化を避ける

6. 利用者

利用者説明主な操作
社内同期研修参加者または同期社員名前入力、診断回答、結果確認
開発メンバー新人研修の開発担当者実装、テスト、デプロイ、発表
研修担当者成果物を確認する担当者要件、設計、動作確認、レビュー

7. 業務フロー

flowchart TD
  A["利用者が診断画面を開く"] --> B["名前を入力する"]
  B --> C["10問の質問に回答する"]
  C --> D["診断ボタンを押す"]
  D --> E["システムがタイプを判定する"]
  E --> F["診断結果をDynamoDBに保存する"]
  F --> G["相性のよい登録者を検索する"]
  G --> H["結果と相性のよい人を表示する"]

8. 画面イメージ

8.1 診断画面

┌──────────────────────────────────────────────┐
│ 同期タイプ診断              回答数 0 / 10      │
│ Sync Type Finder                              │
├──────────────────────────────────────────────┤
│ 名前                                          │
│ [ 佐藤                                  ]     │
│                                              │
│ 1. 新しい課題が始まったとき、最初にやりたいことは? │
│ ┌─────────────────┐ ┌─────────────────┐       │
│ │ まず動くものを作る │ │ 目的と前提を整理する │       │
│ └─────────────────┘ └─────────────────┘       │
│                                              │
│ 2. チームで話し合うときに意識しやすいことは?      │
│ ┌─────────────────┐ ┌─────────────────┐       │
│ │ 意見を出して流れを作る │ │ 全員が話しやすい状態にする │ │
│ └─────────────────┘ └─────────────────┘       │
│                                              │
│ [ 診断する ]                                  │
└──────────────────────────────────────────────┘

8.2 結果画面

┌──────────────────────────────┐
│ 診断結果                      │
│ 佐藤さんはスパークタイプ        │
│                              │
│ 新しいことに素早く反応し、       │
│ 場を前に進めるタイプです。       │
│                              │
│ 相性のよい同期                 │
│ あなたは田中さん、鈴木さんと     │
│ 相性がいい!                  │
│                              │
│ [ もう一度診断する ]            │
└──────────────────────────────┘

9. 機能一覧

ID機能名概要優先度
F-01名前入力利用者が診断者名を入力する必須
F-02質問表示10問の二択質問を表示する必須
F-03回答入力各質問に対して1つの選択肢を選ぶ必須
F-04回答数表示回答済み数を表示する任意
F-05診断判定回答スコアから5タイプのいずれかを判定する必須
F-06診断結果表示名前、タイプ、説明文を表示する必須
F-07診断結果保存名前、タイプ、診断日時をDynamoDBへ保存する必須
F-08相性候補抽出保存済み診断者から相性のよい人を抽出する必須
F-09相性メッセージ表示「あなたは○○さんと相性がいい!」を表示する必須
F-10再診断画面を初期状態に戻して再診断できる任意

10. 機能要件

F-01 名前入力

項目内容
概要利用者が自身の名前を入力できる
入力名前
入力制約1文字以上30文字以下
必須必須
正常時入力した名前を診断結果保存時に使用する
異常時未入力の場合、診断を開始しない
受入条件名前未入力で診断ボタンを押した場合、送信されないこと

F-02 質問表示

項目内容
概要診断用の質問を10問表示する
表示項目質問文、選択肢2件
受入条件10問すべてが画面に表示されること

F-03 回答入力

項目内容
概要各質問に対して二択のうち1つを選択できる
入力方式ラジオボタン
受入条件各質問で同時に選択できる選択肢は1つであること

F-04 回答数表示

項目内容
概要回答済みの質問数を表示する
表示形式回答済み数 / 質問数
受入条件選択状態を変更したとき、回答済み数が更新されること

F-05 診断判定

項目内容
概要回答ごとのスコアを合計し、最も点数の高いタイプを判定する
タイプ数5種類
タイプSPARK、HARBOR、ROOT、CRAFT、BLOOM
同点時定義済みの優先順で1つに決定する
受入条件全回答から1つの診断タイプが返ること

F-06 診断結果表示

項目内容
概要診断者名、診断タイプ、タイプ説明を表示する
表示例佐藤さんはスパークタイプ
受入条件診断完了後に結果画面が表示されること

F-07 診断結果保存

項目内容
概要診断結果をDynamoDBに保存する
保存項目id、name、type、createdAt
保存タイミング診断ボタン押下後、判定完了時
受入条件診断完了後、DynamoDBに1件保存されること

F-08 相性候補抽出

項目内容
概要自分のタイプと相性のよいタイプの登録者を抽出する
除外条件自分自身と同一名のデータは除外する
最大表示件数5件
受入条件相性対象タイプの人だけが返ること

F-09 相性メッセージ表示

項目内容
概要相性のよい人の名前を結果画面に表示する
表示例あなたは田中さんと相性がいい!
候補なしまだ相性のよい同期が登録されていません。 を表示する
受入条件候補あり、候補なしの両方で適切な文言が表示されること

F-10 再診断

項目内容
概要診断フォームを初期状態に戻す
受入条件ボタン押下後、入力内容と回答がクリアされること

11. 非機能要件

ID分類要件指標、方針
NF-01性能30人程度の利用で問題なく動作するAPI応答は通常3秒以内を目安とする
NF-02可用性研修発表時に動作確認できる発表前にデプロイ済みURLで疎通確認する
NF-03セキュリティ保存する個人情報を最小化する保存項目は名前と診断タイプに限定する
NF-04保守性未経験者でも追える構造にするファイル数、機能数を増やしすぎない
NF-05拡張性将来タイプ数や質問数を変更できる質問、タイプ、相性ルールを定数として管理する
NF-06操作性スマートフォンでも回答できる幅860px以下で1カラム表示にする
NF-07コスト研修用途で低コストに運用するDynamoDBはオンデマンド課金、Lambdaは128MB

12. 制約条件

ID制約内容
C-01開発期間11日間で要件定義からテストまで完了させる
C-02スキル開発者は全員未経験者を想定する
C-03AWSサービス最低限Lambda、DynamoDB、API Gatewayを使用する
C-04データ量利用者30人程度を前提にする
C-05法務、表現MBTIという名称や既存タイプ体系は画面上で使用しない

13. 受入基準

ID確認内容合格条件
A-01診断画面表示名前入力、10問、診断ボタンが表示される
A-02未入力制御名前未入力または未回答がある場合、診断できない
A-03タイプ判定全回答後、5タイプのうち1つが表示される
A-04データ保存診断後、DynamoDBに診断者データが保存される
A-05相性表示相性のよいタイプの登録者名が表示される
A-06候補なし表示相性候補がいない場合、候補なしメッセージが表示される
A-07スマホ表示スマホ幅でも質問と結果が読める
A-08テスト判定ロジックと相性抽出の単体テストが成功する

14. V字開発での成果物

工程成果物本プロジェクトでの対応
要件定義本書、画面イメージ、機能一覧docs/requirements.md
基本設計画面設計、API設計、DB設計docs/basic-design.md
詳細設計判定ロジック、バリデーション、エラー処理docs/detailed-design.md
実装ソースコード、SAMテンプレートfrontend/, src/, template.yaml
単体テストテストコード、テスト結果tests/diagnosis.test.mjs
結合テストAPI疎通確認、画面操作確認手動テストで確認
システムテスト受入基準に基づく確認受入基準表で確認

基本設計書

基本設計書

1. 文書情報

項目内容
文書名Sync Type Finder 基本設計書
対象システム社内同期向けオリジナルタイプ診断Webアプリケーション
対応要件docs/requirements.md
主な設計対象画面、API、DynamoDB、AWS構成

2. システム構成

flowchart LR
  U["利用者ブラウザ"] --> F["静的フロントエンド<br>HTML/CSS/JavaScript"]
  F -->|POST /diagnoses| A["API Gateway<br>HTTP API"]
  F -->|GET /diagnoses| A
  A --> L["Lambda<br>diagnosis.handler"]
  L --> D["DynamoDB<br>DiagnosisTable"]

3. コンポーネント責務

コンポーネントファイル、AWSリソース責務
フロントエンドfrontend/index.html画面のHTML構造を定義する
フロントエンドfrontend/styles.cssレイアウト、配色、レスポンシブ表示を定義する
フロントエンドfrontend/app.js質問表示、入力制御、API呼び出し、結果表示を行う
APIAPI Gateway HTTP APIブラウザからのHTTPリクエストをLambdaへ転送する
バックエンドsrc/diagnosis.mjs診断判定、保存、相性候補抽出を行う
DBDynamoDB DiagnosisTable診断者名、診断タイプ、診断日時を保存する
IaCtemplate.yamlAWSリソースをSAMで定義する

4. 画面設計

4.1 画面一覧

画面ID画面名URL概要
SCR-01診断画面/index.html名前入力と診断質問への回答を行う
SCR-02結果画面/index.html診断結果と相性のよい人を表示する

本アプリでは画面遷移を増やさず、1つのHTML内で診断画面と結果画面を表示する。

4.2 SCR-01 診断画面

4.2.1 目的

利用者が名前を入力し、10問の二択質問に回答して診断を開始する。

4.2.2 レイアウト

┌──────────────────────────────────────────────┐
│ 同期タイプ診断              回答数 0 / 10      │
│ Sync Type Finder                              │
├──────────────────────────────────────────────┤
│ 名前                                          │
│ [ name input                            ]     │
│                                              │
│ 質問1                                         │
│ [選択肢A] [選択肢B]                            │
│                                              │
│ 質問2                                         │
│ [選択肢A] [選択肢B]                            │
│                                              │
│ ...                                          │
│                                              │
│ [診断する]                                    │
│ メッセージ表示欄                              │
└──────────────────────────────────────────────┘

4.2.3 画面項目

項目ID項目名種別必須入力、表示仕様
SCR01-I01名前テキスト入力必須1文字以上30文字以下
SCR01-I02質問テキスト表示10問表示
SCR01-I03選択肢ラジオボタン必須各質問につき2択、1つのみ選択可
SCR01-O01回答数テキスト表示回答済み数 / 10
SCR01-B01診断するボタン押下時に入力チェック後、診断処理を実行
SCR01-M01メッセージテキスト表示入力不備、保存失敗時のメッセージを表示

4.2.4 画面イベント

イベントID操作処理
SCR01-E01名前入力入力値をフォームに保持する
SCR01-E02選択肢選択回答済み数を更新する
SCR01-E03診断する押下入力チェック、回答収集、API呼び出し、結果表示を行う

4.3 SCR-02 結果画面

4.3.1 目的

診断結果と、相性のよい同期を利用者に表示する。

4.3.2 レイアウト

┌──────────────────────────────┐
│ 診断結果                      │
│ {name}さんは{label}タイプ      │
│                              │
│ {type description}            │
│                              │
│ 相性のよい同期                 │
│ あなたは{name}さんと相性がいい! │
│                              │
│ [もう一度診断する]              │
└──────────────────────────────┘

4.3.3 画面項目

項目ID項目名種別表示仕様
SCR02-O01診断結果タイトルテキスト表示{診断者名}さんは{タイプ表示名}タイプ
SCR02-O02タイプ説明テキスト表示タイプごとの説明文
SCR02-O03相性候補見出しテキスト表示相性のよい同期
SCR02-O04相性メッセージテキスト表示候補あり、候補なしで文言を切り替える
SCR02-B01もう一度診断するボタンフォーム初期化、結果画面非表示

4.3.4 表示パターン

パターン条件表示
P-01相性候補が1件あなたは田中さんと相性がいい!
P-02相性候補が複数件あなたは田中さん、鈴木さんと相性がいい!
P-03相性候補が0件まだ相性のよい同期が登録されていません。次の人の診断を待ちましょう。

5. API設計

5.1 API一覧

API IDメソッドパス概要利用画面
API-01POST/diagnoses診断結果を保存し、相性候補を返す診断画面
API-02GET/diagnoses保存済み診断者を取得する動作確認、結合テスト

5.2 共通仕様

項目内容
データ形式JSON
文字コードUTF-8
CORS全オリジン許可。研修用途のため最小設定
認証なし
エラー形式{ "message": "エラー内容" }

5.3 API-01 POST /diagnoses

5.3.1 概要

診断回答を受け取り、タイプを判定する。
判定結果をDynamoDBへ保存し、保存済みデータから相性候補を抽出して返す。

5.3.2 リクエスト

POST /diagnoses
Content-Type: application/json
{
  "name": "佐藤",
  "answers": [
    { "SPARK": 2, "CRAFT": 1 },
    { "SPARK": 2, "BLOOM": 1 },
    { "ROOT": 2, "CRAFT": 1 }
  ]
}

5.3.3 リクエスト項目

項目必須制約
namestring必須trim後1文字以上30文字以下
answersarray必須1件以上。通常は10件
answers[].SPARKnumber任意整数
answers[].HARBORnumber任意整数
answers[].ROOTnumber任意整数
answers[].CRAFTnumber任意整数
answers[].BLOOMnumber任意整数

5.3.4 レスポンス

ステータス: 201 Created

{
  "id": "a1b2c3d4-0000-0000-0000-000000000000",
  "name": "佐藤",
  "result": {
    "type": "SPARK",
    "label": "スパーク",
    "compatibleTypes": ["HARBOR", "ROOT"],
    "scores": {
      "SPARK": 5,
      "HARBOR": 0,
      "ROOT": 2,
      "CRAFT": 2,
      "BLOOM": 1
    }
  },
  "matches": [
    {
      "name": "田中",
      "type": "HARBOR",
      "label": "ハーバー"
    }
  ]
}

5.3.5 レスポンス項目

項目内容
idstring保存した診断結果のUUID
namestring診断者名
result.typestring診断タイプコード
result.labelstring診断タイプ表示名
result.compatibleTypesarray相性対象タイプコード
result.scoresobjectタイプ別スコア
matchesarray相性候補
matches[].namestring相性候補者名
matches[].typestring相性候補者タイプ
matches[].labelstring相性候補者タイプ表示名

5.4 API-02 GET /diagnoses

5.4.1 概要

保存済み診断者を取得する。
主に動作確認、結合テスト、研修発表時の確認に使用する。

5.4.2 リクエスト

GET /diagnoses

タイプで絞り込む場合:

GET /diagnoses?type=SPARK

5.4.3 クエリパラメータ

項目必須内容
typestring任意指定したタイプの診断者のみ取得する

5.4.4 レスポンス

ステータス: 200 OK

{
  "items": [
    {
      "name": "佐藤",
      "type": "SPARK",
      "label": "スパーク",
      "createdAt": "2026-06-05T10:00:00.000Z"
    }
  ]
}

6. DB設計

6.1 テーブル一覧

テーブル論理名テーブル物理名用途
診断結果テーブルDiagnosisTable診断者の名前、タイプ、診断日時を保存する

6.2 DiagnosisTable

属性名キー必須説明
idStringパーティションキー必須UUID
nameString必須診断者名
typeString必須診断タイプコード
createdAtString必須ISO8601形式の登録日時

6.3 データ例

{
  "id": "a1b2c3d4-0000-0000-0000-000000000000",
  "name": "佐藤",
  "type": "SPARK",
  "createdAt": "2026-06-05T10:00:00.000Z"
}

6.4 キー設計方針

項目方針
パーティションキーid
GSI作成しない
読み取り方式件数が30人程度のためScanを利用する
重複登録同一人物が複数回診断しても別レコードとして保存する

GSIを作成しない理由は、研修範囲と利用規模を踏まえ、設計と実装を単純化するためである。
実サービス化する場合は、type をパーティションキーにしたGSI、または最新診断のみ保持する設計を検討する。

7. タイプ設計

7.1 タイプ一覧

タイプコード表示名説明
SPARKスパーク新しいことに素早く反応し、場を前に進める
HARBORハーバー周囲をよく見て、安心して話せる空気を作る
ROOTルート物事の土台を整え、着実に形にしていく
CRAFTクラフト細部を観察し、品質や完成度を高める
BLOOMブルーム人の良さや可能性を見つけ、関係を広げる

7.2 相性ルール

自分のタイプ相性のよいタイプ
SPARKHARBOR, ROOT
HARBORSPARK, CRAFT
ROOTSPARK, BLOOM
CRAFTHARBOR, BLOOM
BLOOMROOT, CRAFT

8. AWS設計

8.1 API Gateway

項目設計値
種別HTTP API
CORSGET, POST, OPTIONS を許可
認証なし
統合先Lambda DiagnosisFunction

8.2 Lambda

項目設計値
RuntimeNode.js 20.x
Handlerdiagnosis.handler
Timeout10秒
Memory128MB
環境変数TABLE_NAME
IAM対象DynamoDBテーブルへのCRUD権限

8.3 DynamoDB

項目設計値
BillingModePAY_PER_REQUEST
主キーid
TTL使用しない
Stream使用しない

9. 画面/API連携

sequenceDiagram
  participant User as 利用者
  participant Front as フロントエンド
  participant Api as API Gateway
  participant Lambda as Lambda
  participant DB as DynamoDB

  User->>Front: 名前入力、質問回答
  User->>Front: 診断する押下
  Front->>Front: 入力チェック、回答収集
  Front->>Api: POST /diagnoses
  Api->>Lambda: リクエスト転送
  Lambda->>Lambda: タイプ判定
  Lambda->>DB: PutItem
  Lambda->>DB: Scan
  Lambda->>Lambda: 相性候補抽出
  Lambda->>Api: 結果JSON
  Api->>Front: 結果JSON
  Front->>User: 診断結果、相性候補表示

10. テスト設計方針

テスト種別対象確認観点
単体テストcalculateTypeスコア合計、最大スコアタイプ判定、同点時の動作
単体テストfindMatches相性対象タイプ抽出、自分自身除外、最大5件
結合テストPOST /diagnosesリクエスト送信、保存、レスポンス形式
結合テストGET /diagnoses保存済みデータ取得、タイプ絞り込み
システムテスト画面操作名前入力、10問回答、結果表示、スマホ表示

11. 実装担当分担案

担当主担当領域成果物
メンバーA要件、画面仕様要件定義書、画面項目
メンバーBフロントHTML/CSSindex.html, styles.css
メンバーCフロントJavaScriptapp.js
メンバーDLambda/APIdiagnosis.mjs, API疎通
メンバーEAWS/SAM/テストtemplate.yaml, テスト、デプロイ

詳細設計

詳細設計書

1. 文書情報

項目内容
文書名Sync Type Finder 詳細設計書
対象システム社内同期向けオリジナルタイプ診断Webアプリケーション
対応要件docs/requirements.md
対応基本設計docs/basic-design.md
主な設計対象判定ロジック、バリデーション、エラー処理

2. 詳細設計の対象範囲

対象内容
診断判定回答スコアから5種類のタイプを決定する
相性候補抽出保存済み診断者から相性のよい人を抽出する
入力チェックフロントエンド、バックエンドで入力値を検証する
エラー処理API、画面で異常時の扱いを定義する
テストケース単体テスト、結合テスト、画面テストの観点を定義する

3. 定数設計

3.1 タイプコード

コード表示名優先順位備考
SPARKスパーク1同点時に最も優先
HARBORハーバー2
ROOTルート3
CRAFTクラフト4
BLOOMブルーム5同点時に最も後

同点時は、上記の優先順位に従って1つのタイプを決定する。
これは診断結果を必ず1つにするための仕様である。

3.2 タイプ表示名

const TYPE_LABELS = {
  SPARK: "スパーク",
  HARBOR: "ハーバー",
  ROOT: "ルート",
  CRAFT: "クラフト",
  BLOOM: "ブルーム",
};

3.3 相性ルール

const TYPE_COMPATIBILITY = {
  SPARK: ["HARBOR", "ROOT"],
  HARBOR: ["SPARK", "CRAFT"],
  ROOT: ["SPARK", "BLOOM"],
  CRAFT: ["HARBOR", "BLOOM"],
  BLOOM: ["ROOT", "CRAFT"],
};

4. 質問設計

4.1 質問数

項目設計値
質問数10問
選択肢数各問2択
回答方式ラジオボタン
スコア方式選択肢ごとに複数タイプへ点数を加算

4.2 回答データ構造

フロントエンドからバックエンドへ送信する回答は、タイプコードと点数のオブジェクト配列とする。

[
  { "SPARK": 2, "CRAFT": 1 },
  { "HARBOR": 2, "BLOOM": 1 }
]

4.3 スコア付与ルール

項目内容
主タイプ選択肢の主な傾向に対して2点
補助タイプ関連する傾向に対して1点
未指定タイプ0点

例:

{ "SPARK": 2, "CRAFT": 1 }

この場合、SPARKに2点、CRAFTに1点を加算する。

5. 診断判定ロジック

5.1 対象関数

関数名ファイル役割
calculateTypesrc/diagnosis.mjs回答配列から診断タイプを決定する

5.2 入力

answers: Array<Record<string, number>>

例:

[
  { "SPARK": 2, "CRAFT": 1 },
  { "SPARK": 2, "BLOOM": 1 },
  { "ROOT": 2, "SPARK": 1 }
]

5.3 出力

{
  "type": "SPARK",
  "label": "スパーク",
  "compatibleTypes": ["HARBOR", "ROOT"],
  "scores": {
    "SPARK": 5,
    "HARBOR": 0,
    "ROOT": 2,
    "CRAFT": 1,
    "BLOOM": 1
  }
}

5.4 処理手順

手順内容
1answers が空でない配列であることを確認する
25タイプすべての初期スコアを0にする
3各回答オブジェクトを先頭から順に処理する
4回答内のタイプコードが定義済みタイプであることを確認する
5回答内の点数が整数であることを確認する
6対象タイプのスコアに点数を加算する
7スコアが高い順にタイプを並べ替える
8同点の場合はタイプ優先順位で並べ替える
9先頭のタイプを診断結果とする
10タイプ、表示名、相性タイプ、スコアを返す

5.5 疑似コード

function calculateType(answers):
  if answers is not array or answers length is 0:
    throw validation error

  scores = {
    SPARK: 0,
    HARBOR: 0,
    ROOT: 0,
    CRAFT: 0,
    BLOOM: 0
  }

  for each answer in answers:
    if answer is not object:
      throw validation error

    for each type, value in answer:
      if type is not defined:
        throw validation error

      if value is not integer:
        throw validation error

      scores[type] = scores[type] + value

  sortedTypes = sort scores by:
    1. score desc
    2. type priority asc

  resultType = sortedTypes[0]

  return {
    type: resultType,
    label: TYPE_LABELS[resultType],
    compatibleTypes: TYPE_COMPATIBILITY[resultType],
    scores
  }

5.6 同点時の例

入力:

[
  { "SPARK": 2 },
  { "HARBOR": 2 }
]

スコア:

タイプ点数
SPARK2
HARBOR2
ROOT0
CRAFT0
BLOOM0

結果:

{
  "type": "SPARK"
}

理由: SPARKとHARBORが同点の場合、優先順位が高いSPARKを採用する。

6. 相性候補抽出ロジック

6.1 対象関数

関数名ファイル役割
findMatchessrc/diagnosis.mjs保存済み診断者から相性のよい人を抽出する

6.2 入力

records: Array<DiagnosisRecord>
ownName: string
ownType: string

DiagnosisRecord:

{
  "name": "田中",
  "type": "HARBOR",
  "createdAt": "2026-06-05T10:00:00.000Z"
}

6.3 出力

[
  {
    "name": "田中",
    "type": "HARBOR",
    "label": "ハーバー"
  }
]

6.4 処理手順

手順内容
1自分のタイプに対応する相性タイプ配列を取得する
2保存済み診断者のうち、タイプが相性タイプに含まれるものだけを抽出する
3診断者名をtrim、小文字化して、自分自身と同一名のデータを除外する
4createdAt の昇順で並べる
5最大5件までに制限する
6表示に必要な name, type, label のみに整形して返す

6.5 自分自身の除外仕様

比較対象変換
自分の名前trim().toLowerCase()" Sato ""sato"
保存済みの名前trim().toLowerCase()"sato""sato"

変換後に一致する場合は自分自身とみなし、候補から除外する。
日本語の表記ゆれ、漢字違い、旧字体までは判定しない。

7. バリデーション設計

7.1 フロントエンドのバリデーション

項目ルール実装方法エラー時
名前必須HTML requiredブラウザ標準の入力エラー
名前最大30文字HTML maxlength="30"30文字を超えて入力できない
回答各質問必須radio requiredブラウザ標準の入力エラー
回答数10問すべて回答JSで回答数確認メッセージ欄に表示

7.2 バックエンドのバリデーション

対象ルールエラー内容
request bodyJSONとして解析できる解析不可の場合400
nametrim後1文字以上400
nametrim後30文字以下400
answers配列である400
answers1件以上400
answerオブジェクトである400
type定義済みタイプである400
score整数である400

7.3 バリデーションエラーのレスポンス

{
  "message": "name must be 1 to 30 characters"
}

ステータスコードは 400 Bad Request とする。

7.4 バリデーション方針

方針理由
フロントとバックエンドの両方でチェックする画面以外からAPIを呼ばれる可能性があるため
エラー文言は短くする研修用途のため、実装と確認を単純化する
名前以外の個人情報は入力させない保存データを最小限にするため

8. API処理詳細

8.1 POST /diagnoses

8.1.1 正常処理

手順処理
1リクエストボディをJSONとして解析する
2name を検証し、前後空白を除去する
3answerscalculateType に渡して診断結果を取得する
4UUIDを生成する
5現在日時をISO8601文字列で生成する
6id, name, type, createdAt をDynamoDBに保存する
7DynamoDBから全診断結果をScanで取得する
8findMatches で相性候補を抽出する
9ステータス201で結果JSONを返す

8.1.2 保存データ

{
  "id": "uuid",
  "name": "佐藤",
  "type": "SPARK",
  "createdAt": "2026-06-05T10:00:00.000Z"
}

8.1.3 注意事項

現在の実装では、同じ名前で複数回診断した場合も別レコードとして保存する。
これは、ログインなしで本人性を厳密に判断できないためである。
将来、最新結果のみを保持したい場合は、名前をキーにする設計または認証機能が必要になる。

8.2 GET /diagnoses

8.2.1 正常処理

手順処理
1DynamoDBから全診断結果をScanで取得する
2type クエリパラメータがある場合、そのタイプのみ抽出する
3表示用に name, type, label, createdAt へ整形する
4ステータス200で結果JSONを返す

8.2.2 注意事項

type に未定義タイプが指定された場合でも、現状は空配列を返す。
研修用途では十分だが、厳密にする場合は未定義タイプを400エラーにする。

9. DynamoDBアクセス設計

9.1 PutItem

項目内容
使用タイミングPOST /diagnoses
目的診断結果を1件保存する
保存先TABLE_NAME 環境変数で指定されたテーブル

保存時のAttributeValue:

{
  "id": { "S": "uuid" },
  "name": { "S": "佐藤" },
  "type": { "S": "SPARK" },
  "createdAt": { "S": "2026-06-05T10:00:00.000Z" }
}

9.2 Scan

項目内容
使用タイミングPOST /diagnoses、GET /diagnoses
目的保存済み診断者を取得する
件数想定30件程度

30件程度のためScanを採用する。
大量データを扱う場合は、Scanは非効率なため設計変更が必要である。

10. エラー処理設計

10.1 ステータスコード

ステータス発生条件レスポンス例
200GET /diagnoses 正常終了{ "items": [] }
201POST /diagnoses 正常終了{ "id": "...", "result": {...} }
400リクエスト形式不正、入力不備{ "message": "name must be 1 to 30 characters" }
405未対応メソッド{ "message": "Method not allowed" }
500DynamoDB障害、環境変数不足など{ "message": "Internal server error" }

10.2 フロントエンドのエラー表示

発生箇所条件表示
入力チェック未回答があるすべての質問に回答してください。
API呼び出しHTTPステータスが2xx以外診断結果を保存できませんでした。
API呼び出し通信失敗ブラウザの例外メッセージまたは保存失敗メッセージ

10.3 Lambdaのエラー処理

エラー種別処理
JSON解析エラー400を返す
バリデーションエラー400を返す
未対応HTTPメソッド405を返す
DynamoDBエラーCloudWatch Logsへ出力し、500を返す
TABLE_NAME 未設定CloudWatch Logsへ出力し、500を返す

10.4 ログ出力方針

項目方針
出力先Lambda標準ログ、CloudWatch Logs
出力内容エラーオブジェクト
個人情報必要以上に名前をログへ出さない

11. セキュリティ詳細

観点設計
認証研修用途のためなし
保存データ名前、タイプ、日時のみ
CORS研修用途では全許可。本番用途では配布元ドメインに限定する
XSS画面生成時に質問文、選択肢をエスケープする
権限Lambdaには対象DynamoDBテーブルのCRUD権限のみ付与する

12. テストケース設計

12.1 単体テスト

TC ID対象条件期待結果
UT-01calculateTypeSPARKが最高点SPARKが返る
UT-02calculateTypeHARBORが最高点HARBORが返る
UT-03calculateType同点優先順位が高いタイプが返る
UT-04calculateTypeanswersが空配列エラーになる
UT-05calculateType未定義タイプを含むエラーになる
UT-06calculateTypescoreが小数エラーになる
UT-07findMatches相性タイプが存在する候補が返る
UT-08findMatches自分と同名のレコードがある候補から除外される
UT-09findMatches候補が6件以上最大5件になる
UT-10findMatches候補なし空配列が返る

12.2 結合テスト

TC ID対象条件期待結果
IT-01POST /diagnoses正常なname、answers201が返り、DynamoDBに保存される
IT-02POST /diagnosesname未指定400が返る
IT-03POST /diagnosesanswers未指定400が返る
IT-04GET /diagnosesデータ登録済みitemsに保存済みデータが返る
IT-05GET /diagnoses?type=SPARKSPARK登録済みSPARKのみ返る

12.3 画面テスト

TC ID対象条件期待結果
ST-01診断画面初期表示名前入力、10問、診断ボタンが表示される
ST-02診断画面回答選択回答数が更新される
ST-03診断画面未回答あり診断できない
ST-04結果画面相性候補あり候補者名が表示される
ST-05結果画面相性候補なし候補なし文言が表示される
ST-06レスポンシブスマホ幅1カラムで表示され、文字がはみ出さない

13. 実装上の注意点

項目注意点
質問変更frontend/app.jsquestions を変更する
タイプ変更フロントとバックエンドのタイプ定義を同時に変更する
相性変更フロントのローカル確認用定義とバックエンド定義を合わせる
API接続AWSデプロイ後、API_BASE_URL にAPI Gateway URLを設定する
外部ライブラリ研修負荷を下げるため追加しない

14. 将来改善案

本開発ではスコープ外とするが、実サービス化する場合は以下を検討する。

改善案内容
認証社内アカウントでログインし、本人の最新診断のみ保存する
静的ホスティングS3、CloudFrontでフロントを配信する
DB最適化type で検索できるGSIを追加する
管理画面登録者一覧、削除、集計を行う
入力保護CORSを特定ドメインに限定する
監視CloudWatch AlarmでLambdaエラーを検知する

実際の仕事っぽく進めるなら、方針はかなりはっきりしています。

「木村さんが全部作る」ではなく、「木村さんを実装加速役・技術リードにして、他4人が仕様・設計・テスト・AWS確認を握る」 のが一番いいです。

5人全員未経験寄りで11日しかないなら、全員が全部を均等にやるより、V字工程の成果物に責任者を置いたほうが現実的です。

基本方針

  1. 要件は増やさない
  2. 画面は1画面構成にする
  3. APIは POST /diagnosesGET /diagnoses だけにする
  4. DBは1テーブルだけにする
  5. 認証、管理画面、削除、ランキング、集計は入れない
  6. 木村さんは実装を進めるが、仕様決定者にはしすぎない
  7. 他メンバーは「設計書・テスト・レビュー・発表」で価値を出す

この課題で一番危ないのは、AIを使える人が爆速でコードを書いて、他4人が「何ができているのか分からない」状態になることです。実務だとこれはかなり危険です。納期には間に合っても、レビュー、説明、障害対応、テストが弱くなります。

おすすめの役割分担

役割担当主な責任
PM/要件担当1人スコープ管理、日程管理、要件定義書
画面/UX担当1人画面設計、HTML/CSS、画面テスト
API/DB担当1人API設計、DB設計、DynamoDB確認
テスト/品質担当1人テスト仕様書、単体/結合/受入テスト
技術リード木村さん実装支援、AI活用、詰まり解消、コードレビュー

木村さんは「全部実装する人」ではなく、「全員が進められるようにする人」にしたほうがいいです。実装が必要なところでは強く使う。ただし、仕様や設計の説明責任は各担当が持つ、という形が仕事っぽいです。

11日間の進め方

工程やること
1日目要件定義作る/作らないを決める、画面イメージ、機能一覧
2日目要件定義受入基準、非機能要件、スコープ確定
3日目基本設計画面設計、API設計、DB設計、AWS構成
4日目詳細設計判定ロジック、バリデーション、エラー処理、テスト観点
5日目実装フロント骨組み、Lambda骨組み、SAMテンプレート
6日目実装診断ロジック、API保存、結果表示
7日目実装/単体テスト単体テスト、画面調整、バグ修正
8日目結合テストAPI Gateway/Lambda/DynamoDB連携確認
9日目システムテスト30人分想定データ、スマホ表示、発表環境確認
10日目修正/資料バグ修正、設計書との整合、発表資料
11日目発表デモ、工程説明、振り返り

大事なのは、4日目までに仕様を固定する ことです。
5日目以降に「タイプを増やそう」「管理画面も欲しい」「ランキング欲しい」となったら、実務なら変更要求として扱います。研修では基本的に却下でいいです。

実際の仕事っぽい進行方法
毎日、短い朝会をやるといいです。

話す内容はこれだけで十分です。

  1. 昨日やったこと
  2. 今日やること
  3. 困っていること
  4. 仕様変更になりそうなこと

特に4つ目が大事です。新人チームは「ちょっと便利そうだから追加」が起きやすいです。でもV字工程では、後半の仕様変更は設計書、実装、テスト全部に影響します。

レビューのやり方
レビューはコードだけ見ないほうがいいです。

見る順番はこれがいいです。

  1. 要件定義書と実装が合っているか
  2. 基本設計書とAPI/DBが合っているか
  3. 詳細設計書と判定ロジックが合っているか
  4. テストで受入基準を確認できるか
  5. 最後にコードが読めるか

実務では「コードが動く」より「決めた仕様どおりに動く」が大事です。

AIの使い方
木村さんのAI活用は強みなので、使ったほうがいいです。ただし使い方を決めておくべきです。

おすすめはこうです。

  • AIに実装案を出させる
  • AIに設計書のたたき台を出させる
  • AIにテストケースを洗い出させる
  • AIにコードレビュー観点を出させる
  • ただし、採用判断は人間がする
  • AI生成コードは木村さん以外が説明できる状態にする

発表で「木村がAIで作りました」になると弱いです。
「AIを使って実装速度を上げたが、要件・設計・テストはチームでレビューし、全員が説明できるようにした」と言えると、かなり仕事っぽいです。

このチームで一番大事な成功条件
一番大事なのは、完成度の高い巨大アプリではなく、要件、設計、実装、テストが一本の線でつながっている小さいアプリ を作ることです。

今回なら成功形はこれです。

  • 要件定義書に「名前、診断、保存、相性表示」と書いてある
  • 基本設計書に画面、API、DBが書いてある
  • 詳細設計書に判定ロジック、バリデーション、エラー処理が書いてある
  • 実装がその通りになっている
  • テストでそれを確認している
  • 発表で5人全員が自分の担当を説明できる

これなら、機能が少なくても実務的にはかなり良い成果物です。
逆に、機能が多くても設計書と実装がズレていたら、仕事としては評価されにくいです。

Sync Type Finder システム全体解説資料

1. この資料の目的

この資料は、今回作成した診断サイトの仕組みを初学者にも分かるように説明するための資料です。

単に「このファイルは何をしているか」だけでなく、以下も説明します。

  • このシステムが何をするものなのか
  • 利用者が操作したとき、裏側で何が起きるのか
  • なぜAPI Gateway、Lambda、DynamoDBを使うのか
  • なぜ機能を増やしすぎない設計にしているのか
  • 各ファイルがどの役割を持っているのか
  • V字開発でどの成果物と対応しているのか

2. 今回作るシステムを一言でいうと

社内同期向けのオリジナルタイプ診断サイトです。

利用者は名前を入力し、10問の質問に回答します。
システムは回答内容から5種類のオリジナルタイプのうち1つを判定します。
診断後、名前と診断結果を保存し、すでに診断した同期の中から相性のよい人を表示します。

表示例:

佐藤さんはスパークタイプ
あなたは田中さん、鈴木さんと相性がいい!

3. このシステムでできること

機能内容
名前入力診断する人の名前を入力する
診断回答10問の二択質問に回答する
タイプ判定回答スコアから5種類のタイプのうち1つを決める
結果表示診断タイプと説明文を表示する
結果保存名前、タイプ、診断日時をDynamoDBに保存する
相性表示保存済みの人から相性のよい人を表示する

4. あえて作らないもの

今回の開発では、以下は作りません。

作らないもの理由
ログイン機能11日間、未経験5人の研修では重すぎるため
管理画面診断サイトの最低限の価値に直結しないため
削除、編集機能データ量が30人程度で、初期要件として不要なため
ランキング機能仕様が広がりやすく、設計とテストが増えるため
メール、Slack通知外部連携が増えて難易度が上がるため
既存MBTIの16タイプ既存名称や体系を使わず、研修用のオリジナル診断にするため

これは手抜きではありません。
実際の仕事でも、期間、人数、技術力、目的に合わせて「作らないもの」を決めることはとても重要です。

今回の目的は、大きなアプリを作ることではなく、要件定義、設計、実装、テストがつながった小さなシステムを完成させることです。

5. 全体構成

今回のシステムは、以下のような構成です。

flowchart LR
  User["利用者"] --> Browser["ブラウザ<br>HTML / CSS / JavaScript"]
  Browser --> Api["API Gateway"]
  Api --> Lambda["Lambda"]
  Lambda --> Dynamo["DynamoDB"]

それぞれの役割は以下です。

部品役割
ブラウザ画面を表示し、利用者の入力を受け取る
HTML画面の構造を作る
CSS画面の見た目を整える
JavaScript質問表示、回答収集、API呼び出し、結果表示を行う
API GatewayブラウザからのHTTPリクエストをLambdaに渡す入口
Lambda診断判定、保存、相性候補抽出を行う処理本体
DynamoDB診断結果を保存するデータベース

6. 利用者が診断したときの流れ

利用者が診断ボタンを押すと、以下の順番で処理が進みます。

sequenceDiagram
  participant User as 利用者
  participant Front as ブラウザ画面
  participant Api as API Gateway
  participant Lambda as Lambda
  participant DB as DynamoDB

  User->>Front: 名前を入力し、10問に回答する
  User->>Front: 診断ボタンを押す
  Front->>Front: 入力内容をチェックする
  Front->>Front: 回答をJSON形式にまとめる
  Front->>Api: POST /diagnoses を送る
  Api->>Lambda: リクエストを転送する
  Lambda->>Lambda: 回答からタイプを判定する
  Lambda->>DB: 名前、タイプ、日時を保存する
  Lambda->>DB: 保存済みの診断結果を取得する
  Lambda->>Lambda: 相性のよい人を探す
  Lambda->>Api: 診断結果と相性候補を返す
  Api->>Front: レスポンスを返す
  Front->>User: 結果画面を表示する

7. なぜブラウザだけで完結させないのか

診断タイプを表示するだけなら、ブラウザだけでも作れます。
実際、ローカル確認用にはブラウザ内の localStorage に仮保存する処理も入っています。

しかし、ブラウザだけで完結すると問題があります。

問題説明
他の人の診断結果が見えない自分のブラウザにしかデータが保存されない
相性表示が成立しにくい同期全員の結果を共通の場所に保存できない
AWSを使ったWebアプリ学習になりにくいサーバー側処理、DB保存、API連携を経験できない

そのため、今回の本番想定ではDynamoDBに全員の診断結果を保存します。

8. なぜAPI Gatewayを使うのか

API Gatewayは、ブラウザからLambdaを呼び出すための入口です。

ブラウザは直接Lambda関数を実行できません。
そこで、API GatewayがURLを用意します。

例:

https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/diagnoses

ブラウザはこのURLに対してHTTPリクエストを送ります。
API GatewayはそのリクエストをLambdaに渡します。

今回のAPIは2つだけです。

メソッドパス役割
POST/diagnoses診断結果を保存し、相性候補を返す
GET/diagnoses保存済み診断者を取得する

9. なぜLambdaを使うのか

Lambdaは、サーバーを自分で用意しなくても処理を実行できるAWSサービスです。

通常のWebアプリでは、バックエンド用のサーバーを起動し続けることがあります。
しかし、今回のような小規模な診断サイトでは、常にサーバーを動かし続ける必要はありません。

Lambdaを使うと、リクエストが来たときだけ処理が動きます。

Lambdaで行う処理は以下です。

処理内容
入力チェック名前や回答が正しい形式か確認する
タイプ判定回答スコアから診断タイプを決める
保存DynamoDBに診断結果を保存する
取得DynamoDBから保存済みデータを取得する
相性候補抽出自分と相性のよいタイプの人を探す
レスポンス作成ブラウザに返すJSONを作る

10. なぜDynamoDBを使うのか

DynamoDBはAWSのデータベースサービスです。
今回のシステムでは、診断結果を保存するために使います。

保存するデータはかなりシンプルです。

項目説明
iduuid1件の診断結果を識別するID
name佐藤診断した人の名前
typeSPARK診断タイプ
createdAt2026-06-05T10:00:00.000Z診断日時

今回の利用者は30人程度です。
そのため、複雑な検索設計はせず、DynamoDBのテーブルを1つだけ使います。

実務ではデータ量が多い場合、検索用のインデックスを作ることがあります。
しかし今回は研修用途なので、まずは「保存できる」「取得できる」「相性表示に使える」を優先します。

11. ファイル構成

.
├── docs/
│   ├── requirements.md
│   ├── basic-design.md
│   ├── detailed-design.md
│   └── system-explanation.md
├── frontend/
│   ├── index.html
│   ├── styles.css
│   └── app.js
├── src/
│   └── diagnosis.mjs
├── tests/
│   └── diagnosis.test.mjs
├── template.yaml
├── server.cjs
├── package.json
└── README.md

12. 各ファイルの役割

12.1 frontend/index.html

画面の骨組みを作るファイルです。

HTMLは、画面に何を置くかを決めます。

このファイルには、以下のような部品があります。

  • タイトル
  • 名前入力欄
  • 質問を表示する場所
  • 診断ボタン
  • 結果を表示する場所
  • 再診断ボタン

HTMLだけでは、質問を自動表示したり、診断ボタンを押したときに処理を動かしたりはできません。
その動きは frontend/app.js が担当します。

12.2 frontend/styles.css

画面の見た目を整えるファイルです。

CSSは、画面をどう見せるかを決めます。

このファイルでは、以下を定義しています。

  • 画面全体の幅
  • 2カラム、1カラムのレイアウト
  • ボタンの見た目
  • 質問カードの見た目
  • スマートフォン表示

CSSを分けておくことで、HTMLの構造と見た目の調整を別々に管理できます。

12.3 frontend/app.js

ブラウザ側の動きを担当するファイルです。

主な処理は以下です。

処理内容
質問定義10問の質問文、選択肢、スコアを持つ
質問表示HTMLに質問を描画する
回答数更新何問回答したかを表示する
回答収集選択された回答を配列にまとめる
API呼び出しPOST /diagnoses に回答を送る
ローカル確認API未設定時はlocalStorageで仮保存する
結果表示診断タイプと相性候補を画面に出す

重要なのは、API_BASE_URL です。

const API_BASE_URL = "";

ここが空の場合、ローカル確認モードとして動きます。
AWSデプロイ後は、API GatewayのURLをここに入れます。

例:

const API_BASE_URL = "https://xxxxx.execute-api.ap-northeast-1.amazonaws.com";

12.4 src/diagnosis.mjs

Lambdaで動くバックエンド処理です。

このファイルが、システムの中核です。

主な関数は以下です。

関数役割
calculateType回答から診断タイプを判定する
findMatches保存済みデータから相性のよい人を探す
handlerAPI Gatewayから呼ばれる入口
handleCreatePOST /diagnoses の処理
handleListGET /diagnoses の処理
sanitizeName名前の入力チェック
putItemDynamoDBへ保存
scanRecordsDynamoDBから取得

ブラウザ側にも診断ロジックがありますが、それはローカル確認用です。
本番で信用するべき判定と保存は、Lambda側で行います。

13. 診断ロジックの仕組み

今回の診断は、5つのタイプに点数を加算していく方式です。

タイプは以下です。

タイプコード表示名特徴
SPARKスパーク新しいことに素早く動く
HARBORハーバー場の安心感を作る
ROOTルート土台を整えて進める
CRAFTクラフト品質や細部を高める
BLOOMブルーム人の良さを見つけ広げる

各選択肢にはスコアが設定されています。

例:

{ SPARK: 2, CRAFT: 1 }

これは、以下の意味です。

SPARKに2点
CRAFTに1点

10問すべてのスコアを合計し、一番点数が高いタイプを診断結果にします。

14. 診断ロジックの例

利用者が以下のように回答したとします。

[
  { "SPARK": 2, "CRAFT": 1 },
  { "SPARK": 2, "BLOOM": 1 },
  { "ROOT": 2, "SPARK": 1 }
]

合計するとこうなります。

タイプ点数
SPARK5
HARBOR0
ROOT2
CRAFT1
BLOOM1

一番点数が高いのはSPARKなので、結果はスパークタイプになります。

15. 同点になったらどうするか

点数が同じになる可能性があります。

例:

タイプ点数
SPARK4
HARBOR4
ROOT1
CRAFT1
BLOOM0

この場合、必ず1つの結果を出すために、優先順位を決めています。

優先順位:

SPARK → HARBOR → ROOT → CRAFT → BLOOM

この例では、SPARKとHARBORが同点ですが、SPARKのほうが優先順位が高いのでSPARKになります。

同点ルールを決めておかないと、同じ回答でも結果が不安定になる可能性があります。
実務では、このような細かいルールを設計書に書いておくことが重要です。

16. 相性表示の仕組み

相性は、タイプ同士の対応表で決めています。

自分のタイプ相性のよいタイプ
SPARKHARBOR, ROOT
HARBORSPARK, CRAFT
ROOTSPARK, BLOOM
CRAFTHARBOR, BLOOM
BLOOMROOT, CRAFT

たとえば、自分がSPARKの場合、HARBORまたはROOTの人が相性候補になります。

処理の流れは以下です。

1. 自分の診断タイプを確認する
2. 相性のよいタイプ一覧を取得する
3. DynamoDBに保存されている診断者一覧を取得する
4. 相性のよいタイプの人だけを残す
5. 自分と同じ名前の人を除外する
6. 最大5人まで返す

17. なぜ自分自身を除外するのか

診断後、自分の結果もDynamoDBに保存されます。

そのまま相性候補を探すと、自分自身が候補に含まれる可能性があります。
それを避けるため、同じ名前のデータは相性候補から除外しています。

ただし、ログイン機能がないため、完全に本人を判定できるわけではありません。

例:

佐藤さんが2人いる場合、同じ名前として扱われる可能性がある

今回の研修用途では、そこまで厳密にしない設計にしています。
実サービスであれば、社員IDやログイン機能を使うべきです。

18. 入力チェックの仕組み

入力チェックは、フロントエンドとバックエンドの両方で行います。

18.1 フロントエンド側

ブラウザ側では、以下をチェックします。

項目チェック
名前必須
名前最大30文字
質問各質問で1つ選択必須
回答数10問すべて回答しているか

フロントエンド側でチェックすると、利用者にすぐエラーを伝えられます。

18.2 バックエンド側

Lambda側でもチェックします。

項目チェック
name1文字以上30文字以下
answers空でない配列
answerオブジェクト
type定義済みタイプ
score整数

バックエンドでもチェックする理由は、画面を通さずにAPIを直接呼ぶこともできるからです。
フロントだけのチェックでは、システム全体としては不十分です。

19. エラー処理の仕組み

APIでは、正常時と異常時でステータスコードを分けます。

ステータス意味
200正常に取得できたGET /diagnoses
201正常に作成、保存できたPOST /diagnoses
400入力内容が不正名前が空、回答形式が不正
405対応していないHTTPメソッドPUTやDELETEなど
500サーバー側の問題DynamoDB障害、環境変数不足

エラー時は以下のようなJSONを返します。

{
  "message": "name must be 1 to 30 characters"
}

画面側では、保存に失敗した場合に以下のようなメッセージを表示します。

診断結果を保存できませんでした。

20. ローカル確認モードの仕組み

frontend/app.jsAPI_BASE_URL が空の場合、API Gatewayには接続しません。

代わりに、ブラウザの localStorage に診断結果を保存します。

API_BASE_URL が空
↓
AWSに送らない
↓
ブラウザ内に仮保存
↓
結果画面まで確認できる

これにより、AWSへデプロイする前でも画面の動きや診断結果表示を確認できます。

ただし、localStorage はそのブラウザだけの保存場所です。
他の人のブラウザとは共有されません。
本番想定では、DynamoDBに保存する必要があります。

21. AWSデプロイ時の仕組み

AWSへデプロイするときは、template.yaml を使います。

template.yaml は、AWSリソースをコードとして定義するファイルです。
このような考え方を Infrastructure as Code、略してIaCと呼びます。

今回定義している主なリソースは以下です。

リソース内容
DiagnosisTableDynamoDBテーブル
DiagnosisApiAPI Gateway HTTP API
DiagnosisFunctionLambda関数

SAM CLIを使うと、このテンプレートをもとにAWS上へリソースを作成できます。

sam build
sam deploy --guided

デプロイ後、API GatewayのURLが出力されます。
そのURLを frontend/app.jsAPI_BASE_URL に設定すると、画面からAWSのAPIを呼べるようになります。

22. テストの考え方

テストでは、ただ画面を触るだけではなく、ロジック単位でも確認します。

今回用意しているテストファイルは以下です。

tests/diagnosis.test.mjs

確認していることは主に2つです。

テスト内容
タイプ判定テスト回答スコアから正しいタイプが返るか
相性抽出テスト自分を除外し、相性のよい人だけ返るか

テストを実行するコマンド:

node --test

実務では、画面テスト、APIテスト、DB保存確認も行います。
今回の研修では、最低限以下を確認できればよいです。

確認内容
単体テスト診断ロジック、相性抽出
結合テストAPI Gateway、Lambda、DynamoDBがつながる
システムテスト画面から診断して結果が表示される

23. V字開発との対応

V字開発では、左側で要件と設計を決め、右側でそれをテストします。

flowchart TD
  Req["要件定義"] --> Basic["基本設計"]
  Basic --> Detail["詳細設計"]
  Detail --> Impl["実装"]
  Impl --> Unit["単体テスト"]
  Unit --> Integration["結合テスト"]
  Integration --> System["システムテスト"]

今回の成果物との対応は以下です。

工程成果物内容
要件定義docs/requirements.md何を作るか、何を作らないか
基本設計docs/basic-design.md画面、API、DB、AWS構成
詳細設計docs/detailed-design.md判定ロジック、入力チェック、エラー処理
実装frontend/, src/, template.yaml実際に動くコード
単体テストtests/diagnosis.test.mjsロジックの確認
結合テストAPI疎通確認APIとDBがつながるか
システムテスト画面操作確認利用者目線で動くか

24. この設計で大事な考え方

24.1 小さく作る

今回のシステムは、あえて小さく作っています。

理由は、11日間、5人、全員未経験という条件では、機能を増やしすぎると設計、実装、テストが破綻しやすいからです。

小さく作ることで、以下ができます。

  • 要件を説明しやすい
  • 設計書と実装を一致させやすい
  • テストしやすい
  • 発表で全体像を説明しやすい

24.2 役割を分ける

HTML、CSS、JavaScript、Lambda、DynamoDB、SAMテンプレートは、それぞれ役割が違います。

役割を分けると、修正するときにどこを見ればよいか分かりやすくなります。

例:

やりたいこと見るファイル
画面の構造を変えたいfrontend/index.html
色や余白を変えたいfrontend/styles.css
質問文を変えたいfrontend/app.js
診断判定を変えたいsrc/diagnosis.mjs
AWS構成を変えたいtemplate.yaml

24.3 画面だけを信用しない

フロントエンドで入力チェックをしていても、バックエンドでも入力チェックをします。

これは実務でとても重要です。
画面は利用者にとって便利にするための場所であり、バックエンドはシステムを守るための場所です。

24.4 設計書と実装を対応させる

今回の資料では、要件、基本設計、詳細設計、実装がつながるようにしています。

たとえば:

要件: 診断結果を保存する
↓
基本設計: POST /diagnoses と DiagnosisTable を設計する
↓
詳細設計: handleCreate と PutItem の処理手順を書く
↓
実装: src/diagnosis.mjs にコードを書く
↓
テスト: API実行後にDynamoDBへ保存されたか確認する

このつながりがあると、実務のレビューでも説明しやすくなります。

25. 初学者が理解すべきポイント

このシステムを理解するうえで、最初に押さえるべきポイントは以下です。

ポイント説明
フロントエンド利用者が見る画面
バックエンド画面の裏側で動く処理
APIフロントエンドとバックエンドのやり取りの入口
データベースデータを保存する場所
JSONフロントとバックエンドがやり取りするデータ形式
HTTPメソッドPOSTは作成、GETは取得
バリデーション入力内容が正しいか確認すること
レスポンスAPIが返す結果
ステータスコードAPI処理の成功、失敗を表す番号

26. よくある疑問

Q1. なぜ診断結果を保存するのか

相性表示をするためです。
保存していないと、他の人の診断結果を使って相性候補を出せません。

Q2. なぜDynamoDBは1テーブルだけなのか

保存するデータが少なく、利用者も30人程度だからです。
複雑なテーブル設計にすると、研修期間内で理解しづらくなります。

Q3. なぜScanを使っているのか

データ件数が少ないからです。
30人程度なら全件取得してから絞り込んでも問題になりにくいです。

ただし、実サービスで何千人、何万人が使うならScanは避け、検索用インデックスを設計します。

Q4. なぜログインがないのか

ログインを作ると、認証、権限、ユーザー管理、セキュリティ設計が必要になります。
今回の研修目的では、そこまで広げるより、Webアプリの基本構成を完成させるほうが重要です。

Q5. なぜフロントとLambdaの両方に診断ロジックがあるのか

フロント側の診断ロジックは、ローカル確認用です。
API未設定でも画面の動きを確認できるようにしています。

本番ではLambda側の診断ロジックを正とします。
将来的には、フロントのローカル確認用ロジックを削除し、常にAPI経由にしてもよいです。

27. 発表で説明するときの流れ

発表では、以下の順番で説明すると分かりやすいです。

  1. 何を作ったか
  2. なぜこの題材にしたか
  3. 利用者の操作の流れ
  4. AWS構成
  5. 画面設計
  6. API設計
  7. DB設計
  8. 診断ロジック
  9. テスト
  10. 工夫した点
  11. 苦労した点
  12. 今後の改善案

特に大事なのは、「機能が多い」ことではなく、「なぜこの設計にしたか」を説明することです。

28. このシステムのまとめ

この診断サイトは、以下の流れで動きます。

利用者が回答する
↓
ブラウザが回答を集める
↓
API Gatewayに送る
↓
Lambdaが診断タイプを判定する
↓
DynamoDBに保存する
↓
保存済みデータから相性候補を探す
↓
ブラウザに結果を返す
↓
画面に「あなたは○○さんと相性がいい!」と表示する

今回のシステムは小さいですが、Webアプリケーションの基本要素が含まれています。

  • 画面
  • API
  • サーバー側処理
  • データベース
  • 入力チェック
  • エラー処理
  • テスト
  • AWS構成
  • 設計書

そのため、新人研修のV字開発課題として、全体像を学ぶにはちょうどよい規模のシステムです。

コメント

タイトルとURLをコピーしました