【Next.js + Nest.js】CSVファイルを使って複数同時投稿を実装

## はじめに
今回は、CSVファイルを用いてブログの投稿を複数同時に実装してみようと思います！
現在の実装では、一つずつ投稿する必要があります。例えば、5つの投稿をする際、一つずつ投稿するのは非常に手間がかかります。そのため、CSVファイルを使って投稿の効率を上げることに挑戦します。

### 目標
以下のような画面を目指します。CSVアップロードボタンを押してCSVファイルを選択すると、複数のデータが同時に投稿されるようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2749225/b71f346f-e4ea-7c14-5164-ed961ad0b5d7.png)



## 前提条件
以下の記事の個人ブログに投稿機能を追加したところから、今回の複数同時投稿機能を実装します。まだ投稿機能を実装していない方は、まずそちらから実装してみてください。

https://qiita.com/RintaroNasu/items/0e2924f555b4755c63a4
## 目次
①フロント側の実装
  　-(1)page.tsxでcsvファイルを登録ボタンを作成
  　-(2)apiファイルでエンドポイントの作成
②バックエンド側の実装
③動作確認

## フロント側
まずは必要なライブラリのインストールからしていきます。
以下のコマンドを実行してreact-iconをインストールしてください。
```
npm install react-icons
```

に、page.tsxでCSV登録ボタンを実装し、CSVの形式をJSON型に変換して、そのデータをAPIファイルに送信するところまで行います。まずは、修正したpage.tsxのコード全体を以下に掲載します。
```typescript:src/app/page.tsx
"use client";

import styles from "@/styles/home.module.css";
import { PostManyBlogData, getAllPosts, PostBlogData } from "@/utils/api";
import { PostType } from "@/utils/Types";
import Link from "next/link";
import { useEffect, useState } from "react";
import { BsUpload } from 'react-icons/bs';

export default function Home() {
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");
  const [author, setAuthor] = useState("");
  const [posts, setPosts] = useState<PostType[]>([]);

  useEffect(() => {
    const fetchData = async () => {
      const allPosts: PostType[] = await getAllPosts();
      setPosts(allPosts);
    };
    fetchData();
  }, []);

  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const newPost = {
      title,
      content,
      author,
      createdAt: new Date().toISOString(),
    };
    try {
      await PostBlogData(newPost);
      console.log("Post created successfully!");
      window.location.reload();
      setTitle("");
      setContent("");
      setAuthor("");
    } catch (error) {
      console.error("Failed to create post:", error);
    }
  };

  const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    if (!file) {
      return;
    }
    const reader = new FileReader();
    reader.onload = async () => {
      const csvText = reader.result as string;
      console.log("csvText=" + csvText)
      const parsedData = parseCsv(csvText);
      try {
        await PostManyBlogData(parsedData);
        window.location.reload();
      } catch (error: any) {
        console.error("Failed to upload CSV data:", error);
      }
      event.target.value = '';
    };
    reader.readAsText(file);
  };

  const parseCsv = (csv: string) => {
   const lines = csv.split("\n").map((line) => line.trim());
    const data = lines.slice(1);
    return data.map((line) => {
      const values = line.split(',');
      return {
        title: values[0],
        content: values[1],
        author: values[2],
        createdAt: new Date().toISOString(),
      };
    });
  };

  return (
    <>
      <p className="text-center text-4xl mt-5 font-bold">個人Blog</p>
      <div className={styles.container}>
        <div>
          <form onSubmit={handleSubmit} className="max-w-xl mx-auto p-4 bg-white shadow-md rounded-lg mb-5">
            <div className="mb-4">
              <label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="title">
                Title:
              </label>
              <input id="title" type="text" value={title} onChange={(e) => setTitle(e.target.value)} required className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" />
            </div>
            <div className="mb-4">
              <label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="content">
                Content:
              </label>
              <textarea id="content" value={content} onChange={(e) => setContent(e.target.value)} required className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline h-32"></textarea>
            </div>
            <div className="mb-4">
              <label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="author">
                Author:
              </label>
              <input id="author" type="text" value={author} onChange={(e) => setAuthor(e.target.value)} required className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" />
            </div>
            <button type="submit" className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">
              登録する
            </button>
          </form>
        </div>
        <div className='mb-12 flex flex-col items-center'>
          <label className='flex h-10 w-60 cursor-pointer items-center justify-center gap-2 rounded border px-4 py-2 text-center font-semibold'>
            <BsUpload />
            アップロード
            <input
              type='file'
              accept='.csv'
              onChange={handleFileChange}
              className='hidden'
            />
          </label>
        </div>
        <ul className={styles.postList}>
          {posts.map((post: PostType) => {
            return (
              <Link href={`/posts/${post.id}`} key={post.id}>
                <li className={styles.post}>
                  <h2 className={styles.title}>{post.title}</h2>
                  <p className={styles.author}>By {post.author}</p>
                </li>
              </Link>
            );
          })}
        </ul>
      </div>
    </>
  );
}

```
では、順番にコードの解説をしていきます。

まず、以下のコードを追加することで、ファイルをアップロードするボタンを実装しています。この際、acceptオプションを使用してCSVファイル以外は受け付けないようにしています。また、BsUploadは先ほどインストールしたReact-iconsを使用しています。

次に、アップロードボタンを押したときにhandleFileChange関数が実行されるので、その関数の処理について説明します。

```typescript
<div className='mb-12 flex flex-col items-center'>
    <label className='flex h-10 w-60 cursor-pointer items-center justify-center gap-2 rounded border px-4 py-2 text-center font-semibold'>
        <BsUpload />
        アップロード
        <input
            type='file'
            accept='.csv'
            onChange={handleFileChange}
            className='hidden'
        />
    </label>
</div>
```


それが以下のコードになります。
まず、FileReaderオブジェクトを作成します。FileReaderは、ファイルを非同期的に読み込むためのWeb APIです。次に、reader.onload関数を定義し、ファイルの読み込みが完了するとこの関数が呼び出されます。

読み込んだファイルの内容はcsvTextとして取得されますが、まだJSON型にはなっていません。これをJSON型に変換するために、parseCsv関数を作成します。parseCsv関数は、CSV形式のテキストをJSON型のデータに変換します。

最後に、変換されたデータをPostManyBlogData関数に送信します。
```typescript
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    if (!file) {
      return;
    }
    const reader = new FileReader();
    reader.onload = async () => {
      const csvText = reader.result as string;
      const parsedData = parseCsv(csvText);
      try {
        await PostManyBlogData(parsedData);
        window.location.reload();
      } catch (error: any) {
        console.error("Failed to upload CSV data:", error);
      }
      event.target.value = '';
    };
    reader.readAsText(file);
  };
```
先ほど登場したparseCsv関数についてです。

```typescript
const parseCsv = (csv: string) => {
    const lines = csv.split("\n").map((line) => line.trim());
    const data = lines.slice(1);
    return data.map((line) => {
      const values = line.split(",");
      return {
        title: values[0],
        content: values[1],
        author: values[2],
        createdAt: new Date().toISOString(),
      };
    });
  };
```

まずparseCsv関数に渡されるcsvTextのデータは、以下のような形式です。
```
タイトル,内容,著者
csvデータ1,memo1,author1
csvデータ2,memo2,author2
csvデータ3,memo3,author3
```
このデータをcsv.split("\n")で行ごとに分割し、map関数を利用して新たな配列を作成します。また、trimメソッドを使用して空行や不要な空白を削除します。

次に、lines配列には最初の行（タイトル、内容、著者）が格納されているため、この行をスキップします。これを実現するためにsliceメソッドを使用し、その後、データをJSON型のデータとして格納します。

<br>
<br>

次に、APIファイルでJSON形式のデータをサーバー側に送信するための関数を作成します。この関数では、/posts/manyというエンドポイントを作成し、POSTリクエストを送信します。
```typescript:src/app/utils/api.ts
export async function PostManyBlogData(entries: { title: string; content: string; author: string; createdAt: string }[]) {
  try {
    const response = await fetch(`http://localhost:5050/posts/many`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ entries }),
      cache: "no-store",
    });
    const data = await response.json();
    return data;
  } catch (error) {
    console.error("Failed to create post:", error);
    return null;
  }
}
```

これでフロント側の実装は以上になります！

## バックエンド側
ず、以下のコマンドをターミナルで実行してください。これにより、TypeScriptでデータの変換やバリデーションを行うためのライブラリをインストールできます。これらのライブラリを使用することで、オブジェクトを簡単に変換できます。
```terminal:nestjs-blog-api
npm install class-transformer class-validator
```

最初に型の定義を行います。以下のコードを追加してください。
```typescript:src/posts/post.interface.ts
export class CreatePostDataDto{
  @IsString()
  title: string;
  content: string;
  author: string;
  createdAt: string;
}

export class CreatManyPostDataDto {
  @IsArray()
  @ValidateNested({ each: true })
  @Type(() => CreatePostDataDto)
  entries: CreatePostDataDto[]
}
```


次に、posts.controller.tsに以下のコードを追加してください。これにより、エンドポイントを作成し、リクエストオブジェクトからidを取得します。また、awaitキーワードを使用して、posts.service.tsで定義するcreateManyメソッドが完了するのを待ちます。
```typescript:src/posts/posts.controller.ts
@Post('/many')
  async createMany(@Request() req, @Body() body: CreatManyPostDataDto) {
    const postId = req.id
    return await this.postsService.createMany(postId, body.entries)
  }
```

最後に、posts.service.tsに以下のコードを追加してください。これにより、スプレッド演算子 (...) を使用して各データにpostIdを追加し、mapメソッドを使用して配列内の各オブジェクトを変換します。新しいプロパティ postId を追加した新しいオブジェクトの配列 dataWithPostId を作成します。最後に、PrismaのcreateManyメソッドを使用してデータベースに複数のレコードを一括で作成します。

```typescript:src/posts/posts.service.ts
  async createMany(postId: string, data: CreatePostDataDto[]) {
    try {
      const dataWithPostId = data.map((entry) => ({ ...entry, postId }))
      return await this.prisma.posts.createMany({
        data: dataWithPostId,
      })
    } catch (e) {
      console.error("Error in createMany: ", e);
      throw e
    }
  }
```
## 動作確認
まず、スプレッドシートやエクセルで以下のようなデータを作成してください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2749225/19f128a2-aca9-a185-42fc-5dc2c462d850.png)
データが作成できたら、ファイルを選択してCSVファイルに変換します。
CSVファイルが準備できたら、実際のサイト上で複数投稿ができるか試してみましょう。

## 終わりに
これでCSVファイルを用いた複数同時投稿の実装は以上になります！
最後まで見てくれた方ありがとうございました！