0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Spring Boot チーム開発_交流アプリ責務編

0
Last updated at Posted at 2026-05-12

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
```

などを用いた排他制御を行うことで、
より安全に先着順を保証できる。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?