Spring Boot × React でイベント交流アプリを実装する
実装するMVP機能
- 簡易ログイン
- イベント一覧取得
- イベント詳細取得
- イベント作成
- イベント編集
- イベント削除
- イベント参加
- イベント参加キャンセル
- 参加者一覧取得
- ユーザー情報取得
- プロフィール編集
- 自分の作成イベント一覧
- 自分の参加イベント一覧
- タグ一覧取得
- タグ追加
- イベント形式一覧取得
Backend
server/build.gradle.kts
dependencies {
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.springframework.boot:spring-boot-starter-jdbc")
implementation("org.springframework.boot:spring-boot-starter-validation")
runtimeOnly("org.postgresql:postgresql")
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
server/src/main/resources/application.properties
spring.application.name=event-app
spring.datasource.url=jdbc:postgresql://localhost:5432/event_app
spring.datasource.username=postgres
spring.datasource.password=password
spring.sql.init.mode=always
server/src/main/resources/schema.sql
DROP TABLE IF EXISTS event_tags;
DROP TABLE IF EXISTS event_participants;
DROP TABLE IF EXISTS events;
DROP TABLE IF EXISTS tags;
DROP TABLE IF EXISTS types;
DROP TABLE IF EXISTS users;
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
bio TEXT,
field VARCHAR(255),
profile_icon_url TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE types (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL
);
CREATE TABLE tags (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE
);
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(100) NOT NULL,
event_start_time TIMESTAMP NOT NULL,
event_end_time TIMESTAMP NOT NULL,
capacity SMALLINT NOT NULL,
location VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
target_user VARCHAR(255),
event_image_url TEXT,
event_status VARCHAR(30) NOT NULL DEFAULT 'OPEN',
owner_id BIGINT NOT NULL REFERENCES users(id),
type_id BIGINT NOT NULL REFERENCES types(id),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE event_participants (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
event_id BIGINT NOT NULL REFERENCES events(id),
joined_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, event_id)
);
CREATE TABLE event_tags (
id BIGSERIAL PRIMARY KEY,
event_id BIGINT NOT NULL REFERENCES events(id),
tag_id BIGINT NOT NULL REFERENCES tags(id),
UNIQUE (event_id, tag_id)
);
server/src/main/resources/data.sql
INSERT INTO users (name, email, bio, field, profile_icon_url)
VALUES
('Demo User', 'demo@example.com', 'バックエンド開発を勉強中です。', 'Java / Spring Boot', 'https://example.com/icon1.png'),
('Sample User', 'sample@example.com', 'フロントエンドに興味があります。', 'React / TypeScript', 'https://example.com/icon2.png');
INSERT INTO types (name)
VALUES
('勉強会'),
('ランチ会'),
('交流会'),
('もくもく会'),
('ハッカソン'),
('その他');
INSERT INTO tags (name)
VALUES
('React'),
('TypeScript'),
('Java'),
('Spring Boot'),
('UIUX'),
('データサイエンス');
INSERT INTO events (
title,
event_start_time,
event_end_time,
capacity,
location,
description,
target_user,
event_image_url,
event_status,
owner_id,
type_id
)
VALUES
(
'Spring Boot勉強会',
'2026-05-20 15:00:00',
'2026-05-20 16:00:00',
5,
'会議室A',
'Spring Bootの基本について軽く話します。',
'初心者歓迎',
'https://example.com/event1.png',
'OPEN',
1,
1
),
(
'ランチ交流会',
'2026-05-21 12:00:00',
'2026-05-21 13:00:00',
4,
'ラウンジ',
'ランチをしながら気軽に交流します。',
'誰でも歓迎',
'https://example.com/event2.png',
'OPEN',
2,
2
);
INSERT INTO event_tags (event_id, tag_id)
VALUES
(1, 3),
(1, 4),
(2, 1),
(2, 2);
Backend Java
server/src/main/java/padthai/PadthaiProjectApplication.java
package padthai;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class PadthaiProjectApplication {
public static void main(String[] args) {
SpringApplication.run(PadthaiProjectApplication.class, args);
}
}
server/src/main/java/padthai/config/CorsConfig.java
package padthai.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class CorsConfig {
@Bean
public WebMvcConfigurer corsConfigurer() {
// React開発サーバーからAPIを呼び出せるようにする
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:5173")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*");
}
};
}
}
exception
server/src/main/java/padthai/exception/NotFoundException.java
package padthai.exception;
public class NotFoundException extends RuntimeException {
public NotFoundException(String message) {
super(message);
}
}
server/src/main/java/padthai/exception/BadRequestException.java
package padthai.exception;
public class BadRequestException extends RuntimeException {
public BadRequestException(String message) {
super(message);
}
}
server/src/main/java/padthai/exception/ForbiddenException.java
package padthai.exception;
public class ForbiddenException extends RuntimeException {
public ForbiddenException(String message) {
super(message);
}
}
server/src/main/java/padthai/exception/ExceptionHandler.java
package padthai.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestControllerAdvice
public class ExceptionHandler {
// データが存在しない場合
@org.springframework.web.bind.annotation.ExceptionHandler(NotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public Map<String, Object> handleNotFound(NotFoundException exception) {
return Map.of(
"title", "Not Found",
"status", 404,
"detail", exception.getMessage()
);
}
// 入力値や操作条件が不正な場合
@org.springframework.web.bind.annotation.ExceptionHandler(BadRequestException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, Object> handleBadRequest(BadRequestException exception) {
return Map.of(
"title", "Bad Request",
"status", 400,
"detail", exception.getMessage()
);
}
// 権限がない操作の場合
@org.springframework.web.bind.annotation.ExceptionHandler(ForbiddenException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public Map<String, Object> handleForbidden(ForbiddenException exception) {
return Map.of(
"title", "Forbidden",
"status", 403,
"detail", exception.getMessage()
);
}
}
model
server/src/main/java/padthai/model/event/EventStatus.java
package padthai.model.event;
public enum EventStatus {
OPEN,
CLOSED,
FINISH
}
server/src/main/java/padthai/model/event/Event.java
package padthai.model.event;
import java.time.LocalDateTime;
public class Event {
private final Long id;
private final String title;
private final LocalDateTime eventStartTime;
private final LocalDateTime eventEndTime;
private final Integer capacity;
private final String location;
private final String description;
private final String targetUser;
private final String eventImageUrl;
private final EventStatus eventStatus;
private final Long ownerId;
private final Long typeId;
private final LocalDateTime createdAt;
private final LocalDateTime updatedAt;
public Event(
Long id,
String title,
LocalDateTime eventStartTime,
LocalDateTime eventEndTime,
Integer capacity,
String location,
String description,
String targetUser,
String eventImageUrl,
EventStatus eventStatus,
Long ownerId,
Long typeId,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
this.id = id;
this.title = title;
this.eventStartTime = eventStartTime;
this.eventEndTime = eventEndTime;
this.capacity = capacity;
this.location = location;
this.description = description;
this.targetUser = targetUser;
this.eventImageUrl = eventImageUrl;
this.eventStatus = eventStatus;
this.ownerId = ownerId;
this.typeId = typeId;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
public Long getId() { return id; }
public String getTitle() { return title; }
public LocalDateTime getEventStartTime() { return eventStartTime; }
public LocalDateTime getEventEndTime() { return eventEndTime; }
public Integer getCapacity() { return capacity; }
public String getLocation() { return location; }
public String getDescription() { return description; }
public String getTargetUser() { return targetUser; }
public String getEventImageUrl() { return eventImageUrl; }
public EventStatus getEventStatus() { return eventStatus; }
public Long getOwnerId() { return ownerId; }
public Long getTypeId() { return typeId; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
}
server/src/main/java/padthai/model/user/User.java
package padthai.model.user;
import java.time.LocalDateTime;
public class User {
private final Long id;
private final String name;
private final String email;
private final String bio;
private final String field;
private final String profileIconUrl;
private final LocalDateTime createdAt;
private final LocalDateTime updatedAt;
public User(
Long id,
String name,
String email,
String bio,
String field,
String profileIconUrl,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
this.id = id;
this.name = name;
this.email = email;
this.bio = bio;
this.field = field;
this.profileIconUrl = profileIconUrl;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
public Long getId() { return id; }
public String getName() { return name; }
public String getEmail() { return email; }
public String getBio() { return bio; }
public String getField() { return field; }
public String getProfileIconUrl() { return profileIconUrl; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
}
server/src/main/java/padthai/model/event/Tag.java
package padthai.model.event;
public class Tag {
private final Long id;
private final String name;
public Tag(Long id, String name) {
this.id = id;
this.name = name;
}
public Long getId() { return id; }
public String getName() { return name; }
}
server/src/main/java/padthai/model/event/EventType.java
package padthai.model.event;
public class EventType {
private final Long id;
private final String name;
public EventType(Long id, String name) {
this.id = id;
this.name = name;
}
public Long getId() { return id; }
public String getName() { return name; }
}
dto/event
server/src/main/java/padthai/dto/event/EventCreateRequest.java
package padthai.dto.event;
import java.time.LocalDateTime;
import java.util.List;
public class EventCreateRequest {
private String title;
private LocalDateTime eventStartTime;
private LocalDateTime eventEndTime;
private Integer capacity;
private String location;
private String description;
private String targetUser;
private String eventImageUrl;
private Long ownerId;
private Long typeId;
private List<Long> tagIds;
public String getTitle() { return title; }
public LocalDateTime getEventStartTime() { return eventStartTime; }
public LocalDateTime getEventEndTime() { return eventEndTime; }
public Integer getCapacity() { return capacity; }
public String getLocation() { return location; }
public String getDescription() { return description; }
public String getTargetUser() { return targetUser; }
public String getEventImageUrl() { return eventImageUrl; }
public Long getOwnerId() { return ownerId; }
public Long getTypeId() { return typeId; }
public List<Long> getTagIds() { return tagIds; }
}
server/src/main/java/padthai/dto/event/EventUpdateRequest.java
package padthai.dto.event;
import java.time.LocalDateTime;
import java.util.List;
public class EventUpdateRequest {
private String title;
private LocalDateTime eventStartTime;
private LocalDateTime eventEndTime;
private Integer capacity;
private String location;
private String description;
private String targetUser;
private String eventImageUrl;
private Long ownerId;
private Long typeId;
private List<Long> tagIds;
public String getTitle() { return title; }
public LocalDateTime getEventStartTime() { return eventStartTime; }
public LocalDateTime getEventEndTime() { return eventEndTime; }
public Integer getCapacity() { return capacity; }
public String getLocation() { return location; }
public String getDescription() { return description; }
public String getTargetUser() { return targetUser; }
public String getEventImageUrl() { return eventImageUrl; }
public Long getOwnerId() { return ownerId; }
public Long getTypeId() { return typeId; }
public List<Long> getTagIds() { return tagIds; }
}
server/src/main/java/padthai/dto/event/EventSummaryResponse.java
package padthai.dto.event;
import java.time.LocalDateTime;
import java.util.List;
public class EventSummaryResponse {
private final Long id;
private final String title;
private final LocalDateTime eventStartTime;
private final LocalDateTime eventEndTime;
private final Integer capacity;
private final String location;
private final String eventImageUrl;
private final String eventStatus;
private final Integer participantCount;
private final List<TagResponse> tags;
private final TypeResponse type;
public EventSummaryResponse(
Long id,
String title,
LocalDateTime eventStartTime,
LocalDateTime eventEndTime,
Integer capacity,
String location,
String eventImageUrl,
String eventStatus,
Integer participantCount,
List<TagResponse> tags,
TypeResponse type
) {
this.id = id;
this.title = title;
this.eventStartTime = eventStartTime;
this.eventEndTime = eventEndTime;
this.capacity = capacity;
this.location = location;
this.eventImageUrl = eventImageUrl;
this.eventStatus = eventStatus;
this.participantCount = participantCount;
this.tags = tags;
this.type = type;
}
public Long getId() { return id; }
public String getTitle() { return title; }
public LocalDateTime getEventStartTime() { return eventStartTime; }
public LocalDateTime getEventEndTime() { return eventEndTime; }
public Integer getCapacity() { return capacity; }
public String getLocation() { return location; }
public String getEventImageUrl() { return eventImageUrl; }
public String getEventStatus() { return eventStatus; }
public Integer getParticipantCount() { return participantCount; }
public List<TagResponse> getTags() { return tags; }
public TypeResponse getType() { return type; }
}
server/src/main/java/padthai/dto/event/EventResponse.java
package padthai.dto.event;
import padthai.dto.user.UserResponse;
import java.time.LocalDateTime;
import java.util.List;
public class EventResponse {
private final Long id;
private final String title;
private final LocalDateTime eventStartTime;
private final LocalDateTime eventEndTime;
private final Integer capacity;
private final String location;
private final String description;
private final String targetUser;
private final String eventImageUrl;
private final String eventStatus;
private final Integer participantCount;
private final UserResponse owner;
private final TypeResponse type;
private final List<TagResponse> tags;
public EventResponse(
Long id,
String title,
LocalDateTime eventStartTime,
LocalDateTime eventEndTime,
Integer capacity,
String location,
String description,
String targetUser,
String eventImageUrl,
String eventStatus,
Integer participantCount,
UserResponse owner,
TypeResponse type,
List<TagResponse> tags
) {
this.id = id;
this.title = title;
this.eventStartTime = eventStartTime;
this.eventEndTime = eventEndTime;
this.capacity = capacity;
this.location = location;
this.description = description;
this.targetUser = targetUser;
this.eventImageUrl = eventImageUrl;
this.eventStatus = eventStatus;
this.participantCount = participantCount;
this.owner = owner;
this.type = type;
this.tags = tags;
}
public Long getId() { return id; }
public String getTitle() { return title; }
public LocalDateTime getEventStartTime() { return eventStartTime; }
public LocalDateTime getEventEndTime() { return eventEndTime; }
public Integer getCapacity() { return capacity; }
public String getLocation() { return location; }
public String getDescription() { return description; }
public String getTargetUser() { return targetUser; }
public String getEventImageUrl() { return eventImageUrl; }
public String getEventStatus() { return eventStatus; }
public Integer getParticipantCount() { return participantCount; }
public UserResponse getOwner() { return owner; }
public TypeResponse getType() { return type; }
public List<TagResponse> getTags() { return tags; }
}
server/src/main/java/padthai/dto/event/EventCreateResponse.java
package padthai.dto.event;
public class EventCreateResponse {
private final Long id;
public EventCreateResponse(Long id) {
this.id = id;
}
public Long getId() { return id; }
}
server/src/main/java/padthai/dto/event/TagResponse.java
package padthai.dto.event;
public class TagResponse {
private final Long id;
private final String name;
public TagResponse(Long id, String name) {
this.id = id;
this.name = name;
}
public Long getId() { return id; }
public String getName() { return name; }
}
server/src/main/java/padthai/dto/event/TagCreateRequest.java
package padthai.dto.event;
public class TagCreateRequest {
private String name;
public String getName() { return name; }
}
server/src/main/java/padthai/dto/event/TypeResponse.java
package padthai.dto.event;
public class TypeResponse {
private final Long id;
private final String name;
public TypeResponse(Long id, String name) {
this.id = id;
this.name = name;
}
public Long getId() { return id; }
public String getName() { return name; }
}
dto/user
server/src/main/java/padthai/dto/user/SimpleLoginRequest.java
package padthai.dto.user;
public class SimpleLoginRequest {
private String name;
private String email;
public String getName() { return name; }
public String getEmail() { return email; }
}
server/src/main/java/padthai/dto/user/ProfileUpdateRequest.java
package padthai.dto.user;
public class ProfileUpdateRequest {
private String bio;
private String field;
private String profileIconUrl;
public String getBio() { return bio; }
public String getField() { return field; }
public String getProfileIconUrl() { return profileIconUrl; }
}
server/src/main/java/padthai/dto/user/UserResponse.java
package padthai.dto.user;
import padthai.model.user.User;
public class UserResponse {
private final Long id;
private final String name;
private final String email;
private final String bio;
private final String field;
private final String profileIconUrl;
public UserResponse(
Long id,
String name,
String email,
String bio,
String field,
String profileIconUrl
) {
this.id = id;
this.name = name;
this.email = email;
this.bio = bio;
this.field = field;
this.profileIconUrl = profileIconUrl;
}
public static UserResponse from(User user) {
return new UserResponse(
user.getId(),
user.getName(),
user.getEmail(),
user.getBio(),
user.getField(),
user.getProfileIconUrl()
);
}
public Long getId() { return id; }
public String getName() { return name; }
public String getEmail() { return email; }
public String getBio() { return bio; }
public String getField() { return field; }
public String getProfileIconUrl() { return profileIconUrl; }
}
dto/participant
server/src/main/java/padthai/dto/participant/ParticipantCreateRequest.java
package padthai.dto.participant;
public class ParticipantCreateRequest {
private Long userId;
public Long getUserId() { return userId; }
}
server/src/main/java/padthai/dto/participant/ParticipantCreateResponse.java
package padthai.dto.participant;
import java.time.LocalDateTime;
public class ParticipantCreateResponse {
private final Long id;
private final Long userId;
private final Long eventId;
private final LocalDateTime joinedAt;
public ParticipantCreateResponse(
Long id,
Long userId,
Long eventId,
LocalDateTime joinedAt
) {
this.id = id;
this.userId = userId;
this.eventId = eventId;
this.joinedAt = joinedAt;
}
public Long getId() { return id; }
public Long getUserId() { return userId; }
public Long getEventId() { return eventId; }
public LocalDateTime getJoinedAt() { return joinedAt; }
}
server/src/main/java/padthai/dto/participant/ParticipantResponse.java
package padthai.dto.participant;
import java.time.LocalDateTime;
public class ParticipantResponse {
private final Long userId;
private final String name;
private final String profileIconUrl;
private final LocalDateTime joinedAt;
public ParticipantResponse(
Long userId,
String name,
String profileIconUrl,
LocalDateTime joinedAt
) {
this.userId = userId;
this.name = name;
this.profileIconUrl = profileIconUrl;
this.joinedAt = joinedAt;
}
public Long getUserId() { return userId; }
public String getName() { return name; }
public String getProfileIconUrl() { return profileIconUrl; }
public LocalDateTime getJoinedAt() { return joinedAt; }
}
repository/user
server/src/main/java/padthai/repository/user/UserRepository.java
package padthai.repository.user;
import padthai.dto.user.ProfileUpdateRequest;
import padthai.dto.user.SimpleLoginRequest;
import padthai.model.user.User;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public class UserRepository {
private final JdbcTemplate jdbcTemplate;
public UserRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
// IDからユーザーを取得
public Optional<User> findById(Long userId) {
String sql = """
SELECT id, name, email, bio, field, profile_icon_url, created_at, updated_at
FROM users
WHERE id = ?
""";
List<User> users = jdbcTemplate.query(
sql,
(rs, rowNum) -> new User(
rs.getLong("id"),
rs.getString("name"),
rs.getString("email"),
rs.getString("bio"),
rs.getString("field"),
rs.getString("profile_icon_url"),
rs.getTimestamp("created_at").toLocalDateTime(),
rs.getTimestamp("updated_at").toLocalDateTime()
),
userId
);
return users.stream().findFirst();
}
// 簡易ログイン時にメールアドレスで既存ユーザーを探す
public Optional<User> findByEmail(String email) {
String sql = """
SELECT id, name, email, bio, field, profile_icon_url, created_at, updated_at
FROM users
WHERE email = ?
""";
List<User> users = jdbcTemplate.query(
sql,
(rs, rowNum) -> new User(
rs.getLong("id"),
rs.getString("name"),
rs.getString("email"),
rs.getString("bio"),
rs.getString("field"),
rs.getString("profile_icon_url"),
rs.getTimestamp("created_at").toLocalDateTime(),
rs.getTimestamp("updated_at").toLocalDateTime()
),
email
);
return users.stream().findFirst();
}
// 簡易ログインで未登録ユーザーを作成
public Long create(SimpleLoginRequest request) {
String sql = """
INSERT INTO users (name, email)
VALUES (?, ?)
RETURNING id
""";
return jdbcTemplate.queryForObject(
sql,
Long.class,
request.getName(),
request.getEmail()
);
}
// プロフィール情報を更新
public void update(Long userId, ProfileUpdateRequest request) {
String sql = """
UPDATE users
SET
bio = ?,
field = ?,
profile_icon_url = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""";
jdbcTemplate.update(
sql,
request.getBio(),
request.getField(),
request.getProfileIconUrl(),
userId
);
}
}
repository/event
server/src/main/java/padthai/repository/event/EventRepository.java
package padthai.repository.event;
import padthai.dto.event.EventCreateRequest;
import padthai.dto.event.EventUpdateRequest;
import padthai.model.event.Event;
import padthai.model.event.EventStatus;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public class EventRepository {
private final JdbcTemplate jdbcTemplate;
public EventRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
// イベント一覧を取得
public List<Event> findAll() {
String sql = """
SELECT id, title, event_start_time, event_end_time, capacity,
location, description, target_user, event_image_url,
event_status, owner_id, type_id, created_at, updated_at
FROM events
ORDER BY event_start_time ASC
""";
return jdbcTemplate.query(sql, this::mapToEvent);
}
// イベントIDから1件取得
public Optional<Event> findById(Long eventId) {
String sql = """
SELECT id, title, event_start_time, event_end_time, capacity,
location, description, target_user, event_image_url,
event_status, owner_id, type_id, created_at, updated_at
FROM events
WHERE id = ?
""";
List<Event> events = jdbcTemplate.query(sql, this::mapToEvent, eventId);
return events.stream().findFirst();
}
// ユーザーが作成したイベント一覧を取得
public List<Event> findByOwnerId(Long userId) {
String sql = """
SELECT id, title, event_start_time, event_end_time, capacity,
location, description, target_user, event_image_url,
event_status, owner_id, type_id, created_at, updated_at
FROM events
WHERE owner_id = ?
ORDER BY event_start_time ASC
""";
return jdbcTemplate.query(sql, this::mapToEvent, userId);
}
// ユーザーが参加しているイベント一覧を取得
public List<Event> findJoinedEventsByUserId(Long userId) {
String sql = """
SELECT e.id, e.title, e.event_start_time, e.event_end_time, e.capacity,
e.location, e.description, e.target_user, e.event_image_url,
e.event_status, e.owner_id, e.type_id, e.created_at, e.updated_at
FROM events e
JOIN event_participants ep
ON e.id = ep.event_id
WHERE ep.user_id = ?
ORDER BY e.event_start_time ASC
""";
return jdbcTemplate.query(sql, this::mapToEvent, userId);
}
// イベントを新規作成
public Long create(EventCreateRequest request) {
String sql = """
INSERT INTO events (
title, event_start_time, event_end_time, capacity,
location, description, target_user, event_image_url,
event_status, owner_id, type_id
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'OPEN', ?, ?)
RETURNING id
""";
return jdbcTemplate.queryForObject(
sql,
Long.class,
request.getTitle(),
request.getEventStartTime(),
request.getEventEndTime(),
request.getCapacity(),
request.getLocation(),
request.getDescription(),
request.getTargetUser(),
request.getEventImageUrl(),
request.getOwnerId(),
request.getTypeId()
);
}
// イベントを編集
public void update(Long eventId, EventUpdateRequest request) {
String sql = """
UPDATE events
SET
title = ?,
event_start_time = ?,
event_end_time = ?,
capacity = ?,
location = ?,
description = ?,
target_user = ?,
event_image_url = ?,
type_id = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""";
jdbcTemplate.update(
sql,
request.getTitle(),
request.getEventStartTime(),
request.getEventEndTime(),
request.getCapacity(),
request.getLocation(),
request.getDescription(),
request.getTargetUser(),
request.getEventImageUrl(),
request.getTypeId(),
eventId
);
}
// イベントを削除
public void delete(Long eventId) {
jdbcTemplate.update("DELETE FROM events WHERE id = ?", eventId);
}
// 参加人数を取得
public Integer countParticipants(Long eventId) {
String sql = "SELECT COUNT(*) FROM event_participants WHERE event_id = ?";
return jdbcTemplate.queryForObject(sql, Integer.class, eventId);
}
// SQLの取得結果をEventモデルに変換
private Event mapToEvent(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
return new Event(
rs.getLong("id"),
rs.getString("title"),
rs.getTimestamp("event_start_time").toLocalDateTime(),
rs.getTimestamp("event_end_time").toLocalDateTime(),
rs.getInt("capacity"),
rs.getString("location"),
rs.getString("description"),
rs.getString("target_user"),
rs.getString("event_image_url"),
EventStatus.valueOf(rs.getString("event_status")),
rs.getLong("owner_id"),
rs.getLong("type_id"),
rs.getTimestamp("created_at").toLocalDateTime(),
rs.getTimestamp("updated_at").toLocalDateTime()
);
}
}
server/src/main/java/padthai/repository/event/TagRepository.java
package padthai.repository.event;
import padthai.dto.event.TagCreateRequest;
import padthai.dto.event.TagResponse;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class TagRepository {
private final JdbcTemplate jdbcTemplate;
public TagRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
// タグ一覧を取得
public List<TagResponse> findAll() {
String sql = "SELECT id, name FROM tags ORDER BY id";
return jdbcTemplate.query(
sql,
(rs, rowNum) -> new TagResponse(
rs.getLong("id"),
rs.getString("name")
)
);
}
// 新しいタグを作成
public TagResponse create(TagCreateRequest request) {
String sql = """
INSERT INTO tags (name)
VALUES (?)
RETURNING id, name
""";
return jdbcTemplate.queryForObject(
sql,
(rs, rowNum) -> new TagResponse(
rs.getLong("id"),
rs.getString("name")
),
request.getName()
);
}
// イベントに紐づくタグ一覧を取得
public List<TagResponse> findByEventId(Long eventId) {
String sql = """
SELECT t.id, t.name
FROM tags t
JOIN event_tags et
ON t.id = et.tag_id
WHERE et.event_id = ?
ORDER BY t.id
""";
return jdbcTemplate.query(
sql,
(rs, rowNum) -> new TagResponse(
rs.getLong("id"),
rs.getString("name")
),
eventId
);
}
// イベントに紐づくタグを削除
public void deleteEventTags(Long eventId) {
jdbcTemplate.update("DELETE FROM event_tags WHERE event_id = ?", eventId);
}
// イベントとタグを紐づける
public void insertEventTags(Long eventId, List<Long> tagIds) {
if (tagIds == null) {
return;
}
String sql = "INSERT INTO event_tags (event_id, tag_id) VALUES (?, ?)";
for (Long tagId : tagIds) {
jdbcTemplate.update(sql, eventId, tagId);
}
}
}
server/src/main/java/padthai/repository/event/TypeRepository.java
package padthai.repository.event;
import padthai.dto.event.TypeResponse;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class TypeRepository {
private final JdbcTemplate jdbcTemplate;
public TypeRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
// イベント形式一覧を取得
public List<TypeResponse> findAll() {
String sql = "SELECT id, name FROM types ORDER BY id";
return jdbcTemplate.query(
sql,
(rs, rowNum) -> new TypeResponse(
rs.getLong("id"),
rs.getString("name")
)
);
}
// 形式IDから1件取得
public TypeResponse findById(Long typeId) {
String sql = "SELECT id, name FROM types WHERE id = ?";
return jdbcTemplate.queryForObject(
sql,
(rs, rowNum) -> new TypeResponse(
rs.getLong("id"),
rs.getString("name")
),
typeId
);
}
}
repository/participant
server/src/main/java/padthai/repository/participant/ParticipantRepository.java
package padthai.repository.participant;
import padthai.dto.participant.ParticipantCreateResponse;
import padthai.dto.participant.ParticipantResponse;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class ParticipantRepository {
private final JdbcTemplate jdbcTemplate;
public ParticipantRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
// すでに参加しているか確認
public boolean exists(Long eventId, Long userId) {
String sql = """
SELECT COUNT(*)
FROM event_participants
WHERE event_id = ?
AND user_id = ?
""";
Integer count = jdbcTemplate.queryForObject(
sql,
Integer.class,
eventId,
userId
);
return count != null && count > 0;
}
// イベントに参加
public ParticipantCreateResponse create(Long eventId, Long userId) {
String sql = """
INSERT INTO event_participants (event_id, user_id)
VALUES (?, ?)
RETURNING id, user_id, event_id, joined_at
""";
return jdbcTemplate.queryForObject(
sql,
(rs, rowNum) -> new ParticipantCreateResponse(
rs.getLong("id"),
rs.getLong("user_id"),
rs.getLong("event_id"),
rs.getTimestamp("joined_at").toLocalDateTime()
),
eventId,
userId
);
}
// 参加をキャンセル
public void delete(Long eventId, Long userId) {
String sql = """
DELETE FROM event_participants
WHERE event_id = ?
AND user_id = ?
""";
jdbcTemplate.update(sql, eventId, userId);
}
// イベント参加者一覧を取得
public List<ParticipantResponse> findByEventId(Long eventId) {
String sql = """
SELECT
u.id AS user_id,
u.name,
u.profile_icon_url,
ep.joined_at
FROM event_participants ep
JOIN users u
ON ep.user_id = u.id
WHERE ep.event_id = ?
ORDER BY ep.joined_at ASC
""";
return jdbcTemplate.query(
sql,
(rs, rowNum) -> new ParticipantResponse(
rs.getLong("user_id"),
rs.getString("name"),
rs.getString("profile_icon_url"),
rs.getTimestamp("joined_at").toLocalDateTime()
),
eventId
);
}
// イベント削除時に参加情報も削除
public void deleteByEventId(Long eventId) {
jdbcTemplate.update("DELETE FROM event_participants WHERE event_id = ?", eventId);
}
}
service
server/src/main/java/padthai/service/event/EventService.java
package padthai.service.event;
import padthai.dto.event.*;
import padthai.dto.user.UserResponse;
import padthai.exception.ForbiddenException;
import padthai.exception.NotFoundException;
import padthai.model.event.Event;
import padthai.model.user.User;
import padthai.repository.event.EventRepository;
import padthai.repository.event.TagRepository;
import padthai.repository.event.TypeRepository;
import padthai.repository.participant.ParticipantRepository;
import padthai.repository.user.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class EventService {
private final EventRepository eventRepository;
private final TagRepository tagRepository;
private final TypeRepository typeRepository;
private final UserRepository userRepository;
private final ParticipantRepository participantRepository;
public EventService(
EventRepository eventRepository,
TagRepository tagRepository,
TypeRepository typeRepository,
UserRepository userRepository,
ParticipantRepository participantRepository
) {
this.eventRepository = eventRepository;
this.tagRepository = tagRepository;
this.typeRepository = typeRepository;
this.userRepository = userRepository;
this.participantRepository = participantRepository;
}
// イベント一覧を返す
public List<EventSummaryResponse> getEvents() {
return eventRepository.findAll()
.stream()
.map(this::toSummaryResponse)
.toList();
}
// イベント詳細を返す
public EventResponse getEvent(Long eventId) {
Event event = eventRepository.findById(eventId)
.orElseThrow(() -> new NotFoundException("event not found"));
User owner = userRepository.findById(event.getOwnerId())
.orElseThrow(() -> new NotFoundException("owner not found"));
return new EventResponse(
event.getId(),
event.getTitle(),
event.getEventStartTime(),
event.getEventEndTime(),
event.getCapacity(),
event.getLocation(),
event.getDescription(),
event.getTargetUser(),
event.getEventImageUrl(),
event.getEventStatus().name(),
eventRepository.countParticipants(event.getId()),
UserResponse.from(owner),
typeRepository.findById(event.getTypeId()),
tagRepository.findByEventId(event.getId())
);
}
// イベント作成とタグ紐づけをまとめて行う
@Transactional
public EventCreateResponse createEvent(EventCreateRequest request) {
Long eventId = eventRepository.create(request);
tagRepository.insertEventTags(eventId, request.getTagIds());
return new EventCreateResponse(eventId);
}
// 作成者本人のみイベントを編集できる
@Transactional
public void updateEvent(Long eventId, EventUpdateRequest request) {
Event event = eventRepository.findById(eventId)
.orElseThrow(() -> new NotFoundException("event not found"));
if (!event.getOwnerId().equals(request.getOwnerId())) {
throw new ForbiddenException("only owner can update event");
}
eventRepository.update(eventId, request);
tagRepository.deleteEventTags(eventId);
tagRepository.insertEventTags(eventId, request.getTagIds());
}
// 作成者本人のみイベントを削除できる
@Transactional
public void deleteEvent(Long eventId, Long ownerId) {
Event event = eventRepository.findById(eventId)
.orElseThrow(() -> new NotFoundException("event not found"));
if (!event.getOwnerId().equals(ownerId)) {
throw new ForbiddenException("only owner can delete event");
}
participantRepository.deleteByEventId(eventId);
tagRepository.deleteEventTags(eventId);
eventRepository.delete(eventId);
}
// 自分が作成したイベント一覧を返す
public List<EventSummaryResponse> getCreatedEvents(Long userId) {
return eventRepository.findByOwnerId(userId)
.stream()
.map(this::toSummaryResponse)
.toList();
}
// 自分が参加しているイベント一覧を返す
public List<EventSummaryResponse> getJoinedEvents(Long userId) {
return eventRepository.findJoinedEventsByUserId(userId)
.stream()
.map(this::toSummaryResponse)
.toList();
}
private EventSummaryResponse toSummaryResponse(Event event) {
return new EventSummaryResponse(
event.getId(),
event.getTitle(),
event.getEventStartTime(),
event.getEventEndTime(),
event.getCapacity(),
event.getLocation(),
event.getEventImageUrl(),
event.getEventStatus().name(),
eventRepository.countParticipants(event.getId()),
tagRepository.findByEventId(event.getId()),
typeRepository.findById(event.getTypeId())
);
}
}
server/src/main/java/padthai/service/user/UserService.java
package padthai.service.user;
import padthai.dto.user.ProfileUpdateRequest;
import padthai.dto.user.SimpleLoginRequest;
import padthai.dto.user.UserResponse;
import padthai.exception.NotFoundException;
import padthai.model.user.User;
import padthai.repository.user.UserRepository;
import org.springframework.stereotype.Service;
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
// 既存ユーザーなら取得し,未登録なら作成する
public UserResponse simpleLogin(SimpleLoginRequest request) {
User user = userRepository.findByEmail(request.getEmail())
.orElseGet(() -> {
Long userId = userRepository.create(request);
return userRepository.findById(userId)
.orElseThrow(() -> new NotFoundException("user not found"));
});
return UserResponse.from(user);
}
public UserResponse getUser(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new NotFoundException("user not found"));
return UserResponse.from(user);
}
public UserResponse updateUser(Long userId, ProfileUpdateRequest request) {
userRepository.update(userId, request);
User user = userRepository.findById(userId)
.orElseThrow(() -> new NotFoundException("user not found"));
return UserResponse.from(user);
}
}
server/src/main/java/padthai/service/participant/ParticipantService.java
package padthai.service.participant;
import padthai.dto.participant.ParticipantCreateRequest;
import padthai.dto.participant.ParticipantCreateResponse;
import padthai.dto.participant.ParticipantResponse;
import padthai.exception.BadRequestException;
import padthai.exception.NotFoundException;
import padthai.model.event.Event;
import padthai.repository.event.EventRepository;
import padthai.repository.participant.ParticipantRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ParticipantService {
private final ParticipantRepository participantRepository;
private final EventRepository eventRepository;
public ParticipantService(
ParticipantRepository participantRepository,
EventRepository eventRepository
) {
this.participantRepository = participantRepository;
this.eventRepository = eventRepository;
}
// 参加条件を確認してからイベントに参加する
public ParticipantCreateResponse joinEvent(Long eventId, ParticipantCreateRequest request) {
Event event = eventRepository.findById(eventId)
.orElseThrow(() -> new NotFoundException("event not found"));
if (event.getOwnerId().equals(request.getUserId())) {
throw new BadRequestException("owner cannot join own event");
}
if (participantRepository.exists(eventId, request.getUserId())) {
throw new BadRequestException("already joined");
}
Integer participantCount = eventRepository.countParticipants(eventId);
if (participantCount >= event.getCapacity()) {
throw new BadRequestException("capacity exceeded");
}
return participantRepository.create(eventId, request.getUserId());
}
public void cancel(Long eventId, Long userId) {
participantRepository.delete(eventId, userId);
}
public List<ParticipantResponse> getParticipants(Long eventId) {
return participantRepository.findByEventId(eventId);
}
}
controller
server/src/main/java/padthai/controller/event/EventController.java
package padthai.controller.event;
import padthai.dto.event.*;
import padthai.repository.event.TagRepository;
import padthai.repository.event.TypeRepository;
import padthai.service.event.EventService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api")
public class EventController {
private final EventService eventService;
private final TagRepository tagRepository;
private final TypeRepository typeRepository;
public EventController(
EventService eventService,
TagRepository tagRepository,
TypeRepository typeRepository
) {
this.eventService = eventService;
this.tagRepository = tagRepository;
this.typeRepository = typeRepository;
}
@GetMapping("/events")
public List<EventSummaryResponse> getEvents() {
return eventService.getEvents();
}
@GetMapping("/events/{eventId}")
public EventResponse getEvent(@PathVariable Long eventId) {
return eventService.getEvent(eventId);
}
@PostMapping("/events")
@ResponseStatus(HttpStatus.CREATED)
public EventCreateResponse createEvent(@RequestBody EventCreateRequest request) {
return eventService.createEvent(request);
}
@PutMapping("/events/{eventId}")
public EventResponse updateEvent(
@PathVariable Long eventId,
@RequestBody EventUpdateRequest request
) {
eventService.updateEvent(eventId, request);
return eventService.getEvent(eventId);
}
@DeleteMapping("/events/{eventId}")
public java.util.Map<String, String> deleteEvent(
@PathVariable Long eventId,
@RequestParam Long ownerId
) {
eventService.deleteEvent(eventId, ownerId);
return java.util.Map.of("message", "deleted");
}
@GetMapping("/tags")
public List<TagResponse> getTags() {
return tagRepository.findAll();
}
@PutMapping("/tags")
public TagResponse createTag(@RequestBody TagCreateRequest request) {
return tagRepository.create(request);
}
@GetMapping("/types")
public List<TypeResponse> getTypes() {
return typeRepository.findAll();
}
}
server/src/main/java/padthai/controller/user/UserController.java
package padthai.controller.user;
import padthai.dto.event.EventSummaryResponse;
import padthai.dto.user.ProfileUpdateRequest;
import padthai.dto.user.SimpleLoginRequest;
import padthai.dto.user.UserResponse;
import padthai.service.event.EventService;
import padthai.service.user.UserService;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api")
public class UserController {
private final UserService userService;
private final EventService eventService;
public UserController(
UserService userService,
EventService eventService
) {
this.userService = userService;
this.eventService = eventService;
}
@PostMapping("/auth/simple-login")
public UserResponse simpleLogin(@RequestBody SimpleLoginRequest request) {
return userService.simpleLogin(request);
}
@GetMapping("/user/{userId}")
public UserResponse getUser(@PathVariable Long userId) {
return userService.getUser(userId);
}
@PutMapping("/user/{userId}")
public UserResponse updateUser(
@PathVariable Long userId,
@RequestBody ProfileUpdateRequest request
) {
return userService.updateUser(userId, request);
}
@GetMapping("/users/{userId}/events")
public List<EventSummaryResponse> getCreatedEvents(@PathVariable Long userId) {
return eventService.getCreatedEvents(userId);
}
@GetMapping("/users/{userId}/joined-events")
public List<EventSummaryResponse> getJoinedEvents(@PathVariable Long userId) {
return eventService.getJoinedEvents(userId);
}
}
server/src/main/java/padthai/controller/participant/ParticipantController.java
package padthai.controller.participant;
import padthai.dto.participant.ParticipantCreateRequest;
import padthai.dto.participant.ParticipantCreateResponse;
import padthai.dto.participant.ParticipantResponse;
import padthai.service.participant.ParticipantService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/events/{eventId}/participants")
public class ParticipantController {
private final ParticipantService participantService;
public ParticipantController(ParticipantService participantService) {
this.participantService = participantService;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ParticipantCreateResponse joinEvent(
@PathVariable Long eventId,
@RequestBody ParticipantCreateRequest request
) {
return participantService.joinEvent(eventId, request);
}
@DeleteMapping("/{userId}")
public java.util.Map<String, String> cancel(
@PathVariable Long eventId,
@PathVariable Long userId
) {
participantService.cancel(eventId, userId);
return java.util.Map.of("message", "cancelled");
}
@GetMapping
public List<ParticipantResponse> getParticipants(@PathVariable Long eventId) {
return participantService.getParticipants(eventId);
}
}
Frontend
client/src/types/event.ts
export type Tag = {
id: number;
name: string;
};
export type EventType = {
id: number;
name: string;
};
export type EventSummary = {
id: number;
title: string;
eventStartTime: string;
eventEndTime: string;
capacity: number;
location: string;
eventImageUrl: string | null;
eventStatus: string;
participantCount: number;
tags: Tag[];
type: EventType;
};
export type EventDetail = EventSummary & {
description: string;
targetUser: string | null;
owner: {
id: number;
name: string;
email: string;
bio: string | null;
field: string | null;
profileIconUrl: string | null;
};
};
client/src/types/user.ts
export type User = {
id: number;
name: string;
email: string;
bio: string | null;
field: string | null;
profileIconUrl: string | null;
};
client/src/types/participant.ts
export type Participant = {
userId: number;
name: string;
profileIconUrl: string | null;
joinedAt: string;
};
client/src/api/eventApi.ts
import type { EventDetail, EventSummary, EventType, Tag } from "../types/event";
const BASE_URL = "http://localhost:8080";
export async function fetchEvents(): Promise<EventSummary[]> {
const response = await fetch(`${BASE_URL}/api/events`);
if (!response.ok) {
throw new Error("イベント一覧の取得に失敗しました。");
}
return await response.json();
}
export async function fetchEvent(eventId: number): Promise<EventDetail> {
const response = await fetch(`${BASE_URL}/api/events/${eventId}`);
if (!response.ok) {
throw new Error("イベント詳細の取得に失敗しました。");
}
return await response.json();
}
export async function createEvent(requestBody: unknown) {
const response = await fetch(`${BASE_URL}/api/events`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
throw new Error("イベント作成に失敗しました。");
}
return await response.json();
}
export async function updateEvent(eventId: number, requestBody: unknown) {
const response = await fetch(`${BASE_URL}/api/events/${eventId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
throw new Error("イベント編集に失敗しました。");
}
return await response.json();
}
export async function deleteEvent(eventId: number, ownerId: number) {
const response = await fetch(`${BASE_URL}/api/events/${eventId}?ownerId=${ownerId}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error("イベント削除に失敗しました。");
}
return await response.json();
}
export async function fetchTags(): Promise<Tag[]> {
const response = await fetch(`${BASE_URL}/api/tags`);
if (!response.ok) {
throw new Error("タグ取得に失敗しました。");
}
return await response.json();
}
export async function fetchTypes(): Promise<EventType[]> {
const response = await fetch(`${BASE_URL}/api/types`);
if (!response.ok) {
throw new Error("形式取得に失敗しました。");
}
return await response.json();
}
client/src/api/userApi.ts
import type { EventSummary } from "../types/event";
import type { User } from "../types/user";
const BASE_URL = "http://localhost:8080";
export async function simpleLogin(requestBody: {
name: string;
email: string;
}): Promise<User> {
const response = await fetch(`${BASE_URL}/api/auth/simple-login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
throw new Error("ログインに失敗しました。");
}
return await response.json();
}
export async function fetchUser(userId: number): Promise<User> {
const response = await fetch(`${BASE_URL}/api/user/${userId}`);
if (!response.ok) {
throw new Error("ユーザー取得に失敗しました。");
}
return await response.json();
}
export async function updateUser(userId: number, requestBody: {
bio: string;
field: string;
profileIconUrl: string;
}): Promise<User> {
const response = await fetch(`${BASE_URL}/api/user/${userId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
throw new Error("プロフィール編集に失敗しました。");
}
return await response.json();
}
export async function fetchCreatedEvents(userId: number): Promise<EventSummary[]> {
const response = await fetch(`${BASE_URL}/api/users/${userId}/events`);
if (!response.ok) {
throw new Error("作成イベント取得に失敗しました。");
}
return await response.json();
}
export async function fetchJoinedEvents(userId: number): Promise<EventSummary[]> {
const response = await fetch(`${BASE_URL}/api/users/${userId}/joined-events`);
if (!response.ok) {
throw new Error("参加イベント取得に失敗しました。");
}
return await response.json();
}
client/src/api/participantApi.ts
import type { Participant } from "../types/participant";
const BASE_URL = "http://localhost:8080";
export async function joinEvent(eventId: number, userId: number) {
const response = await fetch(`${BASE_URL}/api/events/${eventId}/participants`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
userId,
}),
});
if (!response.ok) {
throw new Error("イベント参加に失敗しました。");
}
return await response.json();
}
export async function cancelEvent(eventId: number, userId: number) {
const response = await fetch(`${BASE_URL}/api/events/${eventId}/participants/${userId}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error("参加キャンセルに失敗しました。");
}
return await response.json();
}
export async function fetchParticipants(eventId: number): Promise<Participant[]> {
const response = await fetch(`${BASE_URL}/api/events/${eventId}/participants`);
if (!response.ok) {
throw new Error("参加者一覧の取得に失敗しました。");
}
return await response.json();
}
client/src/components/event/EventCard.tsx
import type { EventSummary } from "../../types/event";
type Props = {
event: EventSummary;
onClickDetail: (eventId: number) => void;
};
export function EventCard({ event, onClickDetail }: Props) {
return (
<div>
<h2>{event.title}</h2>
<p>{event.location}</p>
<p>
{event.eventStartTime} 〜 {event.eventEndTime}
</p>
<p>
{event.participantCount} / {event.capacity} 人
</p>
<button onClick={() => onClickDetail(event.id)}>
詳細を見る
</button>
</div>
);
}
client/src/pages/LoginPage.tsx
import { useState } from "react";
import { simpleLogin } from "../api/userApi";
import type { User } from "../types/user";
type Props = {
onLogin: (user: User) => void;
};
export function LoginPage({ onLogin }: Props) {
const [name, setName] = useState("Demo User");
const [email, setEmail] = useState("demo@example.com");
async function handleLogin() {
const user = await simpleLogin({
name,
email,
});
onLogin(user);
}
return (
<main>
<h1>簡易ログイン</h1>
<input
value={name}
onChange={(event) => setName(event.target.value)}
/>
<input
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
<button onClick={handleLogin}>
ログイン
</button>
</main>
);
}
client/src/pages/EventListPage.tsx
import { useEffect, useState } from "react";
import { fetchEvents } from "../api/eventApi";
import { EventCard } from "../components/event/EventCard";
import type { EventSummary } from "../types/event";
type Props = {
onClickDetail: (eventId: number) => void;
onClickCreate: () => void;
};
export function EventListPage({ onClickDetail, onClickCreate }: Props) {
const [events, setEvents] = useState<EventSummary[]>([]);
useEffect(() => {
async function loadEvents() {
const result = await fetchEvents();
setEvents(result);
}
loadEvents();
}, []);
return (
<main>
<h1>イベント一覧</h1>
<button onClick={onClickCreate}>
イベントを作成する
</button>
{events.map((event) => (
<EventCard
key={event.id}
event={event}
onClickDetail={onClickDetail}
/>
))}
</main>
);
}
client/src/pages/EventDetailPage.tsx
import { useEffect, useState } from "react";
import { fetchEvent } from "../api/eventApi";
import { cancelEvent, fetchParticipants, joinEvent } from "../api/participantApi";
import type { EventDetail } from "../types/event";
import type { Participant } from "../types/participant";
import type { User } from "../types/user";
type Props = {
eventId: number;
loginUser: User;
onBack: () => void;
};
export function EventDetailPage({ eventId, loginUser, onBack }: Props) {
const [event, setEvent] = useState<EventDetail | null>(null);
const [participants, setParticipants] = useState<Participant[]>([]);
async function load() {
const eventResult = await fetchEvent(eventId);
const participantResult = await fetchParticipants(eventId);
setEvent(eventResult);
setParticipants(participantResult);
}
useEffect(() => {
load();
}, [eventId]);
async function handleJoin() {
await joinEvent(eventId, loginUser.id);
await load();
}
async function handleCancel() {
await cancelEvent(eventId, loginUser.id);
await load();
}
if (event === null) {
return <p>読み込み中...</p>;
}
return (
<main>
<button onClick={onBack}>戻る</button>
<h1>{event.title}</h1>
<p>{event.description}</p>
<p>{event.location}</p>
<p>
{event.participantCount} / {event.capacity} 人
</p>
<button onClick={handleJoin}>
参加する
</button>
<button onClick={handleCancel}>
参加キャンセル
</button>
<h2>参加者</h2>
{participants.map((participant) => (
<p key={participant.userId}>
{participant.name}
</p>
))}
</main>
);
}
client/src/App.tsx
import { useState } from "react";
import { LoginPage } from "./pages/LoginPage";
import { EventListPage } from "./pages/EventListPage";
import { EventDetailPage } from "./pages/EventDetailPage";
import type { User } from "./types/user";
type Page = "login" | "list" | "detail";
function App() {
const [page, setPage] = useState<Page>("login");
const [loginUser, setLoginUser] = useState<User | null>(null);
const [selectedEventId, setSelectedEventId] = useState<number | null>(null);
if (page === "login") {
return (
<LoginPage
onLogin={(user) => {
setLoginUser(user);
setPage("list");
}}
/>
);
}
if (page === "detail" && loginUser !== null && selectedEventId !== null) {
return (
<EventDetailPage
eventId={selectedEventId}
loginUser={loginUser}
onBack={() => setPage("list")}
/>
);
}
return (
<EventListPage
onClickCreate={() => alert("イベント作成画面へ")}
onClickDetail={(eventId) => {
setSelectedEventId(eventId);
setPage("detail");
}}
/>
);
}
export default App;
まとめ
今回の実装では,コメントを最小限にしつつ,
- どのクラスが何を担当するか
- どこに業務ロジックを書くか
- DBアクセスとAPIレスポンスをどう分けるか
が分かるようにした。
チーム開発では,コメントを書きすぎるよりも,
- クラス名
- メソッド名
- ディレクトリ構成
- 責務分離
を揃えることが,読みやすさにつながると感じた。
# 定員超過時に参加できないように修正
## 修正概要
イベント参加時に、
```text
現在参加人数 >= capacity
```
の場合は参加できないように修正しました。
また、同一ユーザーの重複参加についてもチェックを追加しています。
---
# 修正クラス一覧
```text
server/src/main/java/com/example/eventapp/
├── event/
│ ├── repository/
│ │ └── EventRepository.java
│ └── model/
│ └── Event.java
│
├── participant/
│ ├── service/
│ │ └── ParticipantService.java
│ ├── repository/
│ │ └── ParticipantRepository.java
│ └── exception/
│ ├── CapacityOverException.java
│ └── DuplicateParticipantException.java
```
---
# `event/repository/EventRepository.java`
```java
package com.example.eventapp.event.repository;
import com.example.eventapp.event.model.Event;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.Optional;
// Eventテーブルを操作するRepository
@Repository
@RequiredArgsConstructor
public class EventRepository {
private final JdbcTemplate jdbcTemplate;
// イベント詳細取得
public Optional<Event> findById(Long eventId) {
String sql = """
SELECT
id,
title,
capacity,
event_start_time,
event_end_time,
location,
description,
target_user,
event_status,
owner_id,
type_id
FROM events
WHERE id = ?
""";
return jdbcTemplate.query(
sql,
(rs, rowNum) -> new Event(
rs.getLong("id"),
rs.getString("title"),
rs.getInt("capacity"),
rs.getTimestamp("event_start_time").toLocalDateTime(),
rs.getTimestamp("event_end_time").toLocalDateTime(),
rs.getString("location"),
rs.getString("description"),
rs.getString("target_user"),
rs.getString("event_status"),
rs.getLong("owner_id"),
rs.getLong("type_id")
),
eventId
).stream().findFirst();
}
// 現在の参加人数取得
public Integer countParticipants(Long eventId) {
String sql = """
SELECT COUNT(*)
FROM event_participants
WHERE event_id = ?
""";
return jdbcTemplate.queryForObject(
sql,
Integer.class,
eventId
);
}
}
```
---
# `event/model/Event.java`
```java
package com.example.eventapp.event.model;
import lombok.Getter;
import java.time.LocalDateTime;
// Eventエンティティ
@Getter
public class Event {
private final Long id;
private final String title;
private final Integer capacity;
private final LocalDateTime eventStartTime;
private final LocalDateTime eventEndTime;
private final String location;
private final String description;
private final String targetUser;
private final String eventStatus;
private final Long ownerId;
private final Long typeId;
public Event(
Long id,
String title,
Integer capacity,
LocalDateTime eventStartTime,
LocalDateTime eventEndTime,
String location,
String description,
String targetUser,
String eventStatus,
Long ownerId,
Long typeId
) {
this.id = id;
this.title = title;
this.capacity = capacity;
this.eventStartTime = eventStartTime;
this.eventEndTime = eventEndTime;
this.location = location;
this.description = description;
this.targetUser = targetUser;
this.eventStatus = eventStatus;
this.ownerId = ownerId;
this.typeId = typeId;
}
}
```
---
# `participant/repository/ParticipantRepository.java`
```java
package com.example.eventapp.participant.repository;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
// event_participantsテーブルを操作するRepository
@Repository
@RequiredArgsConstructor
public class ParticipantRepository {
private final JdbcTemplate jdbcTemplate;
// イベント参加登録
public void create(Long eventId, Long userId) {
String sql = """
INSERT INTO event_participants (
event_id,
user_id
)
VALUES (?, ?)
""";
jdbcTemplate.update(
sql,
eventId,
userId
);
}
// 同一ユーザーが既に参加済みか確認
public boolean existsByEventIdAndUserId(
Long eventId,
Long userId
) {
String sql = """
SELECT COUNT(*)
FROM event_participants
WHERE event_id = ?
AND user_id = ?
""";
Integer count = jdbcTemplate.queryForObject(
sql,
Integer.class,
eventId,
userId
);
return count != null && count > 0;
}
}
```
---
# `participant/service/ParticipantService.java`
```java
package com.example.eventapp.participant.service;
import com.example.eventapp.event.model.Event;
import com.example.eventapp.event.repository.EventRepository;
import com.example.eventapp.participant.exception.CapacityOverException;
import com.example.eventapp.participant.exception.DuplicateParticipantException;
import com.example.eventapp.participant.repository.ParticipantRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
// イベント参加に関するService
@Service
@RequiredArgsConstructor
public class ParticipantService {
private final ParticipantRepository participantRepository;
private final EventRepository eventRepository;
// イベント参加処理
@Transactional
public void joinEvent(
Long eventId,
Long userId
) {
Event event = eventRepository.findById(eventId)
.orElseThrow(() ->
new IllegalArgumentException("イベントが存在しません")
);
// 重複参加チェック
boolean alreadyJoined =
participantRepository.existsByEventIdAndUserId(
eventId,
userId
);
if (alreadyJoined) {
throw new DuplicateParticipantException(
"既に参加済みです"
);
}
// 現在参加人数取得
Integer participantCount =
eventRepository.countParticipants(eventId);
// 定員超過チェック
if (participantCount >= event.getCapacity()) {
throw new CapacityOverException(
"定員に達しています"
);
}
// 参加登録
participantRepository.create(
eventId,
userId
);
}
}
```
---
# `participant/exception/CapacityOverException.java`
```java
package com.example.eventapp.participant.exception;
// 定員超過時の例外
public class CapacityOverException extends RuntimeException {
public CapacityOverException(String message) {
super(message);
}
}
```
---
# `participant/exception/DuplicateParticipantException.java`
```java
package com.example.eventapp.participant.exception;
// 重複参加時の例外
public class DuplicateParticipantException extends RuntimeException {
public DuplicateParticipantException(String message) {
super(message);
}
}
```
---
# 実装ポイント
## 1. capacity を超えたら参加不可
```java
if (participantCount >= event.getCapacity())
```
で判定。
---
## 2. 重複参加防止
```java
existsByEventIdAndUserId()
```
で同一イベントへの重複参加を防止。
---
## 3. Service層で制御
参加人数制御は、
```text
RepositoryではなくService
```
で実装。
理由:
```text
ビジネスロジックだから
```
---
# 今後改善できる点
現在はMVP実装として、
```text
参加人数取得
↓
比較
↓
insert
```
を行っている。
ただし、同時アクセス時には、
```text
SELECT ... FOR UPDATE
```
などを用いた排他制御を行うことで、
より安全に先着順を保証できる。