👏

モダンウェブ開発で構築した専門家連携プラットフォーム技術解説

に公開

プロジェクト背景: 友人の起業支援

友人が専門家間の連携を効率化するプラットフォームの構築を目指して起業したとき、彼らのビジョンに共感し、技術面での全面的な支援を行うことを決めました。彼らは専門分野に精通していますが、IT開発の知識は限られていたため、「彼らの専門知識」と「私のエンジニアリングスキル」を組み合わせることで、本当に現場で必要とされるシステムを作り上げることができると考えました。
友人たちのアイデアに技術で貢献できることは、エンジニアとしての私にとって非常にやりがいのあることでした。仕事の合間を縫って夜や週末に開発を進め、定期的に友人たちと機能要件やUIについてディスカッションを重ねながら、約6ヶ月かけてMVPを完成させました。

技術スタックの概要

このプロジェクトでは、最新のJavaScriptエコシステムを活用して、専門家が安全に情報交換できるプラットフォームを構築しました。

  • フロントエンド: Next.js 14, TypeScript, TailwindCSS, shadcn/ui
  • バックエンド: tRPC, Prisma ORM, PostgreSQL
  • 認証: NextAuth.js
  • ストレージ: AWS S3
  • その他: Docker, Trigger.dev
  • デプロイ: Vercel

実装のハイライト(コード例付き)

型安全なAPIレイヤー(tRPC)

tRPCを使用することで、フロントエンドとバックエンド間の型安全な通信を実現しました。友人たちは非エンジニアですが、明確な業務要件を提示してくれたため、それを正確にコードに落とし込むことができました。

// src/server/api/routers/project.ts
export const projectRouter = createTRPCRouter({
  getAll: protectedProcedure
    .input(
      z.object({
        userId: z.number().optional(),
        organizationId: z.number().optional(),
      })
    )
    .query(async ({ ctx, input }) => {
      // 認証済みユーザーのみアクセス可能
      return await ctx.prisma.project.findMany({
        where: {
          userId: input.userId,
          organizationId: input.organizationId,
        },
        include: {
          user: true,
          organization: true,
          projectMessages: {
            orderBy: {
              createdAt: 'desc',
            },
            take: 1,
          },
        },
      });
    }),
    
  create: protectedProcedure
    .input(projectCreateSchema)
    .mutation(async ({ ctx, input }) => {
      // 新規プロジェクトの作成ロジック
      return await ctx.prisma.project.create({
        data: {
          userId: input.userId,
          organizationId: input.organizationId,
          type: input.type,
        },
      });
    }),
});

Prismaによるデータモデリング

友人たちが複雑な業務フローを説明する中で、それをデータモデルとして設計する作業は特に重要でした。彼らの専門知識を活かした綿密なディスカッションを通じて、実務に即した柔軟なデータ構造を実現しました。

// prisma/schema.prisma
model Project {
  id           Int           @id @default(autoincrement())
  uuid         String        @default(uuid())
  createdAt    DateTime      @default(now())
  updatedAt    DateTime      @default(now()) @updatedAt
  type         ProjectType   @default(Main)
  
  // 関連付け
  userId       Int
  user         User          @relation(fields: [userId], references: [id], onDelete: Cascade)
  organizationId Int
  organization Organization  @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  
  // メッセージ管理
  projectMessages            ProjectMessage[]
  projectMessageReadStatuses ProjectMessageReadStatus[]
  
  // 親子関係
  projectId    Int?          @unique
  project      Project?      @relation("ProjectHierarchy", fields: [projectId], references: [id], onDelete: Cascade)
  subProject   Project?      @relation("ProjectHierarchy")
}

S3を活用したファイル管理

友人たちが特に重視していたセキュアなファイル管理については、AWS S3と連携したシステムを構築しました。機密性の高いファイルを安全に扱えるよう、細部まで配慮しています。

// src/utils/s3.ts
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3Client = new S3Client({
  region: process.env.AWS_REGION!,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
});

export const generatePresignedUploadUrl = async (key: string, contentType: string) => {
  const command = new PutObjectCommand({
    Bucket: process.env.S3_BUCKET_NAME,
    Key: key,
    ContentType: contentType,
  });
  
  return await getSignedUrl(s3Client, command, { expiresIn: 3600 });
};

export const generatePresignedDownloadUrl = async (key: string) => {
  const command = new GetObjectCommand({
    Bucket: process.env.S3_BUCKET_NAME,
    Key: key,
  });
  
  return await getSignedUrl(s3Client, command, { expiresIn: 3600 });
};

ユーザーフレンドリーなUI

友人たちは「使いやすさ」を何よりも重視していました。ITに詳しくない専門家でも直感的に使えるUIを実現するため、shadcn/uiとTailwindCSSを駆使し、何度もフィードバックを反映させながら改善を重ねました。

// src/components/FileUploader.tsx
import { useState } from "react";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "./ui/card";
import { Button } from "./ui/button";
import { Progress } from "./ui/progress";
import { generatePresignedUploadUrl } from "@/utils/s3";
import { api } from "@/utils/api";

export function FileUploader({ projectUuid }: { projectUuid: string }) {
  const [file, setFile] = useState<File | null>(null);
  const [uploading, setUploading] = useState(false);
  const [progress, setProgress] = useState(0);
  
  const { mutate: registerFile } = api.files.register.useMutation({
    onSuccess: () => {
      // ファイル登録成功時の処理
      setFile(null);
      setProgress(0);
    }
  });

  const handleUpload = async () => {
    if (!file) return;
    
    setUploading(true);
    
    try {
      // ファイル名をユニークにするためUUIDを使用
      const fileKey = `projects/${projectUuid}/${Date.now()}-${file.name}`;
      
      // S3署名付きURLを取得
      const uploadUrl = await generatePresignedUploadUrl(fileKey, file.type);
      
      // 進捗状況を追跡しながらアップロード
      const xhr = new XMLHttpRequest();
      xhr.open("PUT", uploadUrl);
      xhr.setRequestHeader("Content-Type", file.type);
      
      xhr.upload.onprogress = (event) => {
        if (event.lengthComputable) {
          const percentComplete = Math.round((event.loaded / event.total) * 100);
          setProgress(percentComplete);
        }
      };
      
      xhr.onload = () => {
        if (xhr.status === 200) {
          // ファイル情報をデータベースに登録
          registerFile({
            projectUuid,
            fileName: file.name,
            fileKey,
            fileSize: file.size,
            fileType: file.type
          });
        }
      };
      
      xhr.send(file);
    } catch (error) {
      console.error("ファイルアップロードエラー:", error);
    } finally {
      setUploading(false);
    }
  };

  return (
    <Card>
      <CardHeader>
        <CardTitle>ファイルアップロード</CardTitle>
      </CardHeader>
      <CardContent>
        <input
          type="file"
          onChange={(e) => setFile(e.target.files?.[0] || null)}
          disabled={uploading}
          className="w-full"
        />
        {uploading && <Progress value={progress} className="mt-2" />}
      </CardContent>
      <CardFooter>
        <Button 
          onClick={handleUpload} 
          disabled={!file || uploading}
        >
          {uploading ? "アップロード中..." : "アップロード"}
        </Button>
      </CardFooter>
    </Card>
  );
}

デプロイとインフラ構築

このプロジェクトでは、開発から本番環境までシームレスなワークフローを構築することにも注力しました。

Vercelを活用した継続的デプロイ

Next.jsプロジェクトの本番環境としてVercelを選択しました。GitHubリポジトリと連携することで、以下のメリットを得ることができました。
自動デプロイ - ブランチへのプッシュごとに自動的にプレビュー環境が生成され、友人たちに最新の変更を即座に確認してもらうことができました
環境変数の管理 - 開発環境と本番環境の設定を簡単に管理
プレビューデプロイ - PRごとの一時的な環境で変更の影響を安全に確認
ロールバック機能 - 問題が発生した場合に即座に以前のバージョンに戻せる安心感

# vercel.json
{
  "buildCommand": "prisma generate && next build",
  "installCommand": "npm install",
  "framework": "nextjs",
  "regions": ["hnd1"],
  "env": {
    "DATABASE_URL": "@database_url",
    "NEXTAUTH_SECRET": "@nextauth_secret",
    "NEXTAUTH_URL": "@nextauth_url"
  }
}

データベース設定

Vercel Postgresを利用することで、インフラ管理の手間を大幅に削減しました。データベースのマイグレーションはVercelのデプロイパイプラインに組み込むことで自動化しました。

// package.json
{
  "scripts": {
    "build": "prisma migrate deploy && prisma generate && next build",
    "vercel-build": "prisma migrate deploy && prisma generate && next build"
  }

プライベートネットワーク構成

本番環境では、AWS VPCとVercelを連携させ、データベースへのアクセスをプライベートネットワーク内に限定することでセキュリティを強化しました。これにより、パブリックインターネットからのデータベースアクセスを完全に遮断しています。

環境構築の自動化

開発環境のセットアップを容易にするため、Docker Composeを活用:

# docker-compose.yml
version: '3.8'
services:
  postgres:
    image: postgres:14
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
      POSTGRES_DB: app
    volumes:
      - postgres-data:/var/lib/postgresql/data

  minio:
    image: minio/minio
    ports:
      - "9000:9000"
      - "9001:9001"
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin
    command: server /data --console-address ":9001"
    volumes:
      - minio-data:/data

volumes:
  postgres-data:
  minio-data:

これにより、新しいチームメンバーは docker-compose up コマンド一つで開発環境を立ち上げることができ、環境構築の時間を大幅に短縮しました。

開発プロセスと協力体制

友人との協力体制は、このプロジェクトの成功に不可欠でした。彼らは専門家としての知見を惜しみなく提供してくれただけでなく、ユーザー視点からの貴重なフィードバックも常に与えてくれました。

定期的なフィードバックサイクル

毎週末にオンラインミーティングを設け、新機能のデモや改善点について議論しました。彼らはITの専門家ではありませんが、ユーザー体験に関する鋭い指摘をしてくれたことで、本当に使いやすいシステムに仕上げることができました。

アジャイル開発の実践

小さな機能を素早く実装し、フィードバックを得て改善するサイクルを繰り返しました。特に初期段階では、画面遷移やデータフローについてモックアップを共有し、細部を詰めていく作業が重要でした。

// src/app/projects/[id]/page.tsx
import { notFound } from "next/navigation";
import { prisma } from "@/server/db";
import { MessageList } from "@/components/MessageList";
import { MessageForm } from "@/components/MessageForm";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/pages/api/auth/[...nextauth]";

export default async function ProjectPage({ params }: { params: { id: string } }) {
  const session = await getServerSession(authOptions);
  
  if (!session) {
    return <div>ログインが必要です</div>;
  }
  
  const project = await prisma.project.findUnique({
    where: { uuid: params.id },
    include: {
      user: true,
      organization: true,
    },
  });
  
  if (!project) {
    notFound();
  }
  
  // アクセス権限チェック - 友人たちの要件から設計
  const canAccess = 
    (session.user.type === 'Lawyer' && project.userId === session.user.id) ||
    (session.user.type === 'Expert' && project.organizationId === session.user.organizationId);
    
  if (!canAccess) {
    return <div>アクセス権限がありません</div>;
  }

  return (
    <div className="container mx-auto py-6">
      <h1 className="text-2xl font-bold mb-6">
        プロジェクト: {project.uuid}
      </h1>
      
      <div className="bg-white rounded-lg shadow p-6 mb-6">
        <div className="grid grid-cols-2 gap-4">
          <div>
            <h2 className="text-lg font-semibold">依頼者</h2>
            <p>{project.user.name}</p>
          </div>
          <div>
            <h2 className="text-lg font-semibold">担当組織</h2>
            <p>{project.organization.name}</p>
          </div>
        </div>
      </div>
      
      <MessageList projectId={project.id} />
      <MessageForm projectId={project.id} />
    </div>
  );
}

プロジェクトを通じて得たもの

友人たちの起業を技術面から支援することは、単なるコーディング以上の価値がありました。

専門分野の知識習得

知見のない分野の業務フローを深く理解することで、エンジニアとして新たな視点を得ることができました。専門家の実際の課題に向き合うことで、システム開発の本質的な価値を再確認できました。

本当に必要とされるものを作る喜び

友人たちが「これがあれば業務が劇的に効率化される」と喜ぶ姿を見ることは、エンジニアとして何よりも嬉しい瞬間でした。技術的に洗練されているだけでなく、実際に現場で役立つものを作り上げる経験は非常に貴重なものでした。

長期的な関係の構築

このプロジェクトを通じて友人たちとの絆が深まり、彼らのビジネスが成長するにつれて、継続的に技術面での支援を行える関係を築くことができました。彼らの成功が自分の成功にも繋がるという実感は、とても価値のあるものです。

まとめ

友人の起業を支援するこのプロジェクトは、技術力を活かして人の役に立つことの充実感を再確認させてくれました。彼らの専門知識と私のエンジニアリングスキルを組み合わせることで、どちらか単独では実現できなかった価値あるプロダクトを生み出すことができました。
このプロジェクトで得た経験は、私自身のエンジニアとしての成長にも大きく貢献しています。技術だけでなく、ビジネスニーズを理解しプロダクトに落とし込む力、そして何より人との協働を通じて新しい価値を生み出す喜びを学ぶことができました。
今後も友人たちのビジネスが成長する中で、技術面からのサポートを続けていくことを楽しみにしています。このような協力関係を築けたことは、エンジニアとしての私にとって大きな財産となっています。

付記

本記事で紹介したコード例およびアーキテクチャの説明は、実際のプロジェクトの本質を伝えるために一部簡略化・修正を加えています。実際の実装では、より複雑なセキュリティ対策やビジネスロジックが組み込まれていますが、プロジェクトの機密性を保護するため、具体的な実装詳細は一部省略・変更しています。また、ドメイン固有の用語やモデル名は一般化しています。
技術的なコンセプトと開発プロセスの共有を主な目的としているため、ご了承ください。

Discussion