【個人開発】Chatendarのイベント機能を深掘り:Next.js(TS)とFlask(Python)での実装を解説
はじめに
このブログでは、IT未経験で独学の私が開発したアプリである「Chatendar」の主要な機能について解説していきます。今回は、イベント作成機能に焦点を当て、その具体的な実装方法をご紹介します。
もし、全体のコードをご覧になりたい方は、私のGitHubにて公開していますので、そちらからご確認いただけます。
アプリの全体像や概要を知りたい方は、以下のサイトからご確認ください。
イベント作成画面
紹介する機能
・イベント名、日付、時間、場所、説明、友達を招待するための項目を含む、イベント(予定)フォームの作成機能。
この機能を実装するためのコードを紹介します。
コード
ここでは、フロントエンド(client)とバックエンド(server)に分かれたコードを紹介します。
イベント(予定)フォーム
フロントエンド
以下はフロントエンド(Next.js)の解説です。
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>
);
}
※一部コードを省略しています。
const {
eventName,
setEventName,
meetingTime,
setMeetingTime,
meetingPlace,
setMeetingPlace,
description,
setDescription,
invitedFriends,
setInvitedFriends,
error,
setError,
} = useEventForm();
このコードは、イベント名、時間、場所、説明などの項目を管理するためのカスタムフックを使用し、イベントに関連する状態を管理しています。
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
で利用できるようにしています。
以下のcsrfToken
とapi
のコードは、JSX内で記述された<form onSubmit={handleSubmit}>
が送信された際に呼び出される非同期関数の一部です。
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'
という値が取得されます。
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
リクエストを送信するものです。
api
はutils/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
という拡張機能を使って実装しています。
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'
によって、自動的に付加されるためです。
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_bp = Blueprint('event_bp', __name__)
このコードは、event_bp
という名前でBlueprint
をインスタンス化しています。__name__
には、このモジュールの名前が入ります。Flaskはこれを利用してリソースを正しく位置づけます。
次に、event_bp
に対して各ルートを紐付けます。以下の例では、create_event
関数が/create
エンドポイントに対してPOST
メソッドで登録されています。
event_bp.route('/create', methods=['POST'])(create_event)
このように、/create
エンドポイントにはイベント作成用のcreate_event
関数が対応しており、POST
メソッドでリクエストを受け取ります。
Blueprint
を用いることで、コードをモジュール化し、各機能の役割がより明確になります。これにより可読性が向上し、複雑なアプリケーションでも管理がしやすくなります。
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
: イベントが作成された日時を保存する。
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
data = request.get_json()
クライアントから送信されたJSONデータを取得します。
user_id = get_jwt_identity()
JWTを使用して認証されているユーザーのIDを取得します。
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