🎫

【個人開発】Chatendarのイベント機能を深掘り:Next.js(TS)とFlask(Python)での実装を解説

2024/10/21に公開

はじめに

このブログでは、IT未経験で独学の私が開発したアプリである「Chatendar」の主要な機能について解説していきます。今回は、イベント作成機能に焦点を当て、その具体的な実装方法をご紹介します。

もし、全体のコードをご覧になりたい方は、私のGitHubにて公開していますので、そちらからご確認いただけます。

https://github.com/R-koma/calendar-chat

アプリの全体像や概要を知りたい方は、以下のサイトからご確認ください。

https://qiita.com/ryoma_itngineer/items/1a45121a2317b47d2003

https://zenn.dev/ryoma_itngineer/articles/2eff5031d08295

イベント作成画面

cc-side-modal.png

cc-create-event.png

紹介する機能

・イベント名、日付、時間、場所、説明、友達を招待するための項目を含む、イベント(予定)フォームの作成機能。

この機能を実装するためのコードを紹介します。

コード

ここでは、フロントエンド(client)とバックエンド(server)に分かれたコードを紹介します。

イベント(予定)フォーム

フロントエンド

以下はフロントエンド(Next.js)の解説です。

CalendarEventCreateForm.tsx
import AddIcon from '@mui/icons-material/Add';
import CloseIcon from '@mui/icons-material/Close';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import { useEventForm } from '@/hooks/useEventForm';
import { User } from '@/types/User';
import api from '@/utils/api';
import InviteFriendsModal from './InviteFriendsModal';

type ModalProps = {
  isModalOpen: boolean;
  closeModal: () => void;
  selectedDate: Date | null;
  handleDateChange: (date: Date | null) => void;
  isInviteModalOpen: boolean;
  setInviteModalOpen: (isOpen: boolean) => void;
  fetchEvents: () => void;
};

export default function CalendarEventCreateForm({
  isModalOpen,
  closeModal,
  selectedDate,
  handleDateChange,
  isInviteModalOpen,
  setInviteModalOpen,
  fetchEvents,
}: ModalProps) {
  const {
    eventName,
    setEventName,
    meetingTime,
    setMeetingTime,
    meetingPlace,
    setMeetingPlace,
    description,
    setDescription,
    invitedFriends,
    setInvitedFriends,
    error,
    setError,
  } = useEventForm();


  const handleOpenInviteModal = () => setInviteModalOpen(true);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      const csrfToken = document.cookie
        .split('; ')
        .find((row) => row.startsWith('csrf_access_token='))
        ?.split('=')[1];

      if (!csrfToken) {
        throw new Error('CSRF token not found');
      }

      await api.post(
        '/event/create',
        {
          event_name: eventName,
          event_date: selectedDate,
          meeting_time: meetingTime,
          meeting_place: meetingPlace,
          description,
          invitees: invitedFriends.map((friend) => friend.id),
        },
        { headers: { 'X-CSRF-TOKEN': csrfToken } },
      );
      // フォームのリセット  
      setEventName('');
      handleDateChange(null);
      setMeetingTime('');
      setMeetingPlace('');
      setDescription('');
      setInvitedFriends([]);

      fetchEvents();
      closeModal();
    } catch (err) {
      setError('イベントの作成に失敗しました');
    }
  };

  return (
    <div
      className={`fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50 ${
        isModalOpen ? '' : 'hidden'
      }`}
    >
      <div className="bg-gray-800 p-6 rounded shadow-lg w-96 relative">
        <CloseIcon
          className="absolute top-2 right-2 icon-extra-small cursor-pointer"
          onClick={closeModal}
        />
        <h2 className="text-lg text-center mb-4 font-bold">イベント作成</h2>
        <form onSubmit={handleSubmit}>
          <div className="mb-2">
            <label className="block text-xxs font-bold">
              イベント名
              <input
                type="text"
                className="w-full h-8 p-2 border rounded text-gray-700 text-xs outline-none"
                value={eventName}
                onChange={(e) => setEventName(e.target.value)}
              />
            </label>
          </div>
          <div className="mb-2">
            <div className="block text-xxs font-bold">
              日付
              <DatePicker
                id="date-picker"
                selected={selectedDate}
                onChange={handleDateChange}
                className="w-full h-8 p-2 border rounded text-gray-700 text-xxs outline-none"
              />
            </div>
          </div>
          <div className="mb-2">
            <label className="block text-xxs font-bold">
              時間
              <select
                className="w-full h-8 p-2 border rounded text-gray-700 text-xs outline-none"
                value={meetingTime}
                onChange={(e) => setMeetingTime(e.target.value)}
              >
                <option value="未定">未定</option>
                <option value="00:00">0:00</option>
                <option value="01:00">1:00</option>
                一部省略
              </select>
            </label>
          </div>
          <div className="mb-2">
            <label className="block text-xxs font-bold">
              場所
              <input
                type="text"
                className="w-full h-8 p-2 border rounded text-gray-700 text-xs outline-none"
                value={meetingPlace}
                onChange={(e) => setMeetingPlace(e.target.value)}
              />
            </label>
          </div>
          <div className="mb-2">
            <label className="block text-xxs font-bold">
              説明
              <textarea
                className="w-full h-12 p-2 border rounded text-gray-700 text-xxs outline-none"
                value={description}
                onChange={(e) => setDescription(e.target.value)}
              />
            </label>
          </div>
          <div className="flex justify-center">
            <button
              type="button"
              className="flex items-center mr-2 p-2 border-none rounded bg-gray-400 text-xxs h-6"
              onClick={closeModal}
            >
              キャンセル
            </button>
            <button
              type="submit"
              className="flex items-center p-2 border-none rounded bg-blue-500 text-xxs text-white h-6"
            >
              作成
            </button>
          </div>
        </form>
      </div>
    </div>
  );
}

※一部コードを省略しています。

CalendarEventCreateForm.tsx
const {
    eventName,
    setEventName,
    meetingTime,
    setMeetingTime,
    meetingPlace,
    setMeetingPlace,
    description,
    setDescription,
    invitedFriends,
    setInvitedFriends,
    error,
    setError,
  } = useEventForm();

このコードは、イベント名、時間、場所、説明などの項目を管理するためのカスタムフックを使用し、イベントに関連する状態を管理しています。

useEventForm.tsからインポートしており、コードは以下の通りです。

useEventForm.ts
'use client';

import { useState, useEffect } from 'react';
import { EventDetail } from '@/types/Event';
import { User } from '@/types/User';

export function useEventForm(initialEvent: EventDetail | null = null) {
  const [eventName, setEventName] = useState(initialEvent?.event_name || '');
  const [meetingTime, setMeetingTime] = useState(
    initialEvent?.meeting_time || '',
  );
  const [meetingPlace, setMeetingPlace] = useState(
    initialEvent?.meeting_place || '',
  );
  const [description, setDescription] = useState(
    initialEvent?.description || '',
  );
  const [invitedFriends, setInvitedFriends] = useState<User[]>([]);
  const [participants, setParticipants] = useState(
    initialEvent?.participants || [],
  );
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (initialEvent) {
      setEventName(initialEvent.event_name);
      setMeetingTime(initialEvent.meeting_time);
      setMeetingPlace(initialEvent.meeting_place);
      setDescription(initialEvent.description);
      setParticipants(initialEvent.participants);
    }
  }, [initialEvent]);

  return {
    eventName,
    setEventName,
    meetingTime,
    setMeetingTime,
    meetingPlace,
    setMeetingPlace,
    description,
    setDescription,
    invitedFriends,
    setInvitedFriends,
    participants,
    setParticipants,
    error,
    setError,
  };
}

このコードは、イベントフォームに関連する状態を管理するためのカスタムフックです。例えば、const [eventName, setEventName] = useState(initialEvent?.event_name || '');では、イベント名とその更新関数を定義しています。initialEventが渡されている場合はそのevent_nameを初期値に、渡されていない場合は空の文字列が初期値になります。

この仕組みにより、フォーム内の必要な情報を個別に管理し、それらを動的に更新できるようになっています。

また、useEffectフックを利用して、initialEventが変更されたときに、状態を更新し、コンポーネントが再レンダリングされるようにしています。

最終的に、このフックはイベントに関連するすべての状態と、それらを更新するための関数をオブジェクトとして返し、CalendarEventCreateForm.tsxで利用できるようにしています。

以下のcsrfTokenapiのコードは、JSX内で記述された<form onSubmit={handleSubmit}>が送信された際に呼び出される非同期関数の一部です。

csrfToken
const csrfToken = document.cookie
    .split('; ')
    .find((row) => row.startsWith('csrf_access_token='))
    ?.split('=')[1];

  if (!csrfToken) {
    throw new Error('CSRF token not found');
  }

これは、CSRFトークンを取得するためのコードで、document.cookieからcrsr_access_tokenを取得しています。このトークンは、CSRF攻撃からアプリケーションを守るために使用されます。

例えば、ブラウザのクッキーに以下のような情報が保存されていたとします。

"csrf_access_token=abcdef12345; user_session=xyz789; theme=light"

document.cookieがこのような文字列を返し、.split('; ')によってクッキーの内容が ["csrf_access_token=abcdef12345", "user_session=xyz789", "theme=light"]のように分割されます。

続いて、.find((row) => row.startsWith('csrf_access_token='))によって、"csrf_access_token=abcdef12345"を見つけ、?.split('=')[1]['csrf_access_token', 'abcdef12345']に分割します。

この結果、csrfTokenには'abcdef12345'という値が取得されます。

CalendarEventCreateForm.tsx
import api from '@/utils/api';

await api.post(
    '/event/create',
    {
      event_name: eventName,
      event_date: selectedDate,
      meeting_time: meetingTime,
      meeting_place: meetingPlace,
      description,
      invitees: invitedFriends.map((friend) => friend.id),
    },
    { headers: { 'X-CSRF-TOKEN': csrfToken } },
);

このコードは、サーバーに対してイベントを作成するためのHTTP POSTリクエストを送信するものです。

apiutils/apiからインポートされており、実際にはaxiosというHTTPクライアントライブラリを使用してデータをサーバーに送信しています。

/event/createは、イベント作成に対応するAPIエンドポイントを示しており、このURLにリクエストを送信することで、サーバー側でイベント作成の処理が行われます。

リクエストボディ
{
    event_name: eventName,
    event_date: selectedDate,
    meeting_time: meetingTime,
    meeting_place: meetingPlace,
    description,
    invitees: invitedFriends.map((friend) => friend.id),
}
リクエストボディには、サーバーに送信するためのデータがJSON形式で格納されています。このデータには、イベントの詳細が含まれています。

```tsx:リクエストヘッダー
{ headers: { 'X-CSRF-TOKEN': csrfToken } }

これはリクエストヘッダーです。headersはHTTPリクエストに付加されるメタデータを表し、ここではCSRFトークンが含まれています。CSRF攻撃を防ぐために、このトークンをリクエストに含めることで、サーバー側はリクエストが正当なクライアントから送信されたものであるかを確認できます。このトークンは通常、クッキーやセッションストレージから取得され、サーバーに送信されます。

バックエンド

以下はバックエンド側 (Flask) の実装についての解説です。今回はBlueprintという拡張機能を使って実装しています。

routes/__init__.py
from .event_routes import event_bp

def init_routes(app):
    app.register_blueprint(event_bp, url_prefix='/event')

このコードでは、同じディレクトリ内のevent_routes.pyからevent_bpというBlueprintオブジェクトをインポートし、init_routes関数で全てのルートに/eventというプレフィックスを付けています。

これにより、例えばevent_bpで定義される/createというルートは、実際には /event/createという形でアクセスできます。
以下のファイルevent_routes.pyをご覧いただくと、それぞれのevent_bp.routeの()内に/eventというプレフィックスはありません。

次に、event_routes.pyの内容についてです。
このファイル内のevent_bp.route()メソッドの引数には/eventというプレフィックスがありません。これは前述のurl_prefix='/event'によって、自動的に付加されるためです。

routes/event_routes.py
from flask import Blueprint
from app.controllers.event_controller import (
    create_event,
)


event_bp = Blueprint('event_bp', __name__)

event_bp.route('/create', methods=['POST'])(create_event)

event_routes.pyでは、event_controller.pyから各種関数をインポートし、それらをルートに紐付けています。

event_routes.py
event_bp = Blueprint('event_bp', __name__)

このコードは、event_bpという名前でBlueprintをインスタンス化しています。__name__ には、このモジュールの名前が入ります。Flaskはこれを利用してリソースを正しく位置づけます。

次に、event_bpに対して各ルートを紐付けます。以下の例では、create_event関数が/createエンドポイントに対してPOSTメソッドで登録されています。

event_routes.py
event_bp.route('/create', methods=['POST'])(create_event)

このように、/createエンドポイントにはイベント作成用のcreate_event関数が対応しており、POSTメソッドでリクエストを受け取ります。

Blueprintを用いることで、コードをモジュール化し、各機能の役割がより明確になります。これにより可読性が向上し、複雑なアプリケーションでも管理がしやすくなります。

models/event_model.py
from .. import db
from datetime import datetime, timezone, timedelta


jst = timezone(timedelta(hours=9))


class Event(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    event_name = db.Column(db.String(100), nullable=False)
    event_date = db.Column(db.DateTime, nullable=True)
    meeting_time = db.Column(db.String(50), nullable=True)
    meeting_place = db.Column(db.String(100), nullable=True)
    description = db.Column(db.Text, nullable=True)
    created_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(jst))

このコードは、イベントのデータベース構造です。
id: イベントの主キーで、各イベントを一意に識別。
event_name: イベント名。最大100文字入力することができる。nullを許可しない。
event_date: イベントの日付と時間を表し、nullを許可する。
meeting_time: ミーティングの時間を示す文字列、nullを許可する。
meeting_place: ミーティングの場所、最大100文字入力することができる。nullを許可する。
description: イベントの説明を保存するテキストフィールド。
created_by: user.idを参照する外部キーで、イベントの作成者を示す。このフィールドは必須。
created_at: イベントが作成された日時を保存する。

controllers/event_controller.py
from datetime import datetime
from zoneinfo import ZoneInfo
from flask import request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.models import db, Event, EventInvite, EventParticipant, User

"""
`@jwt_required()`デコレーターは、この関数を呼び出す際にJWT認証が必要であることを示します。つまり、認証されたユーザーのみがこの関数にアクセスできます。
"""
@jwt_required()
def create_event():
    try:
        data = request.get_json()
        user_id = get_jwt_identity()

        event_date_str = data.get('event_date')
        event_date = (
            datetime.fromisoformat(event_date_str.replace('Z', '+00:00'))
            if event_date_str
            else None
        )

        event = Event(
            event_name=data.get('event_name'),
            event_date=event_date,
            meeting_time=data.get('meeting_time'),
            meeting_place=data.get('meeting_place'),
            description=data.get('description'),
            created_by=user_id,
        )
        db.session.add(event)
        db.session.commit()

        return jsonify({'message': 'Event created successfully.'}), 201
    except Exception as e:
        db.session.rollback()
        logger.error(f"Error creating event: {str(e)}")
        return jsonify({'error': str(e)}), 500

event_controller.py
data = request.get_json()

クライアントから送信されたJSONデータを取得します。

event_controller.py
user_id = get_jwt_identity()

JWTを使用して認証されているユーザーのIDを取得します。

event_controller.py
event = Event(
    event_name=data.get('event_name'),
    event_date=event_date,
    meeting_time=data.get('meeting_time'),
    meeting_place=data.get('meeting_place'),
    description=data.get('description'),
    created_by=user_id,
)
db.session.add(event)
db.session.commit()

このコードは、Eventモデルのインスタンスを作成し、データベースに保存する処理です。
db.session.add(event)で新しいイベントオブジェクトをセッションに追加し、db.session.commit()でそのデータをデータベースに保存します。

まとめ

今回のブログでは、私が開発したアプリ「Chatendar」のイベント作成機能について、主要な実装部分を解説しました。

イベント作成機能は、アプリケーションにおいてユーザーにとって視覚的にも機能的にも重要な部分です。今後も「Chatendar」の他の機能について、引き続き解説していく予定です。

Discussion