├── .github └── workflows │ └── maven.yml ├── .gitignore ├── .mvn └── wrapper │ ├── MavenWrapperDownloader.java │ ├── maven-wrapper.jar │ └── maven-wrapper.properties ├── README.md ├── _build.gradle_ ├── mvnw ├── mvnw.cmd ├── pom.xml └── src ├── main ├── java │ └── com │ │ └── studyolle │ │ ├── App.java │ │ ├── infra │ │ ├── config │ │ │ ├── AppConfig.java │ │ │ ├── AppProperties.java │ │ │ ├── AsyncConfig.java │ │ │ ├── SecurityConfig.java │ │ │ └── WebConfig.java │ │ └── mail │ │ │ ├── ConsoleEmailService.java │ │ │ ├── EmailMessage.java │ │ │ ├── EmailService.java │ │ │ └── HtmlEmailService.java │ │ └── modules │ │ ├── account │ │ ├── Account.java │ │ ├── AccountController.java │ │ ├── AccountPredicates.java │ │ ├── AccountRepository.java │ │ ├── AccountService.java │ │ ├── CurrentAccount.java │ │ ├── PersistentLogins.java │ │ ├── SettingsController.java │ │ ├── UserAccount.java │ │ ├── form │ │ │ ├── NicknameForm.java │ │ │ ├── Notifications.java │ │ │ ├── PasswordForm.java │ │ │ ├── Profile.java │ │ │ └── SignUpForm.java │ │ └── validator │ │ │ ├── NicknameValidator.java │ │ │ ├── PasswordFormValidator.java │ │ │ └── SignUpFormValidator.java │ │ ├── event │ │ ├── Enrollment.java │ │ ├── EnrollmentRepository.java │ │ ├── Event.java │ │ ├── EventController.java │ │ ├── EventRepository.java │ │ ├── EventService.java │ │ ├── EventType.java │ │ ├── event │ │ │ ├── EnrollmentAcceptedEvent.java │ │ │ ├── EnrollmentEvent.java │ │ │ ├── EnrollmentEventListener.java │ │ │ └── EnrollmentRejectedEvent.java │ │ ├── form │ │ │ └── EventForm.java │ │ └── validator │ │ │ └── EventValidator.java │ │ ├── main │ │ ├── ExceptionAdvice.java │ │ └── MainController.java │ │ ├── notification │ │ ├── Notification.java │ │ ├── NotificationController.java │ │ ├── NotificationInterceptor.java │ │ ├── NotificationRepository.java │ │ ├── NotificationService.java │ │ └── NotificationType.java │ │ ├── study │ │ ├── Study.java │ │ ├── StudyController.java │ │ ├── StudyRepository.java │ │ ├── StudyRepositoryExtension.java │ │ ├── StudyRepositoryExtensionImpl.java │ │ ├── StudyService.java │ │ ├── StudySettingsController.java │ │ ├── event │ │ │ ├── StudyCreatedEvent.java │ │ │ ├── StudyEventListener.java │ │ │ └── StudyUpdateEvent.java │ │ ├── form │ │ │ ├── StudyDescriptionForm.java │ │ │ └── StudyForm.java │ │ └── validator │ │ │ └── StudyFormValidator.java │ │ ├── tag │ │ ├── Tag.java │ │ ├── TagForm.java │ │ ├── TagRepository.java │ │ └── TagService.java │ │ └── zone │ │ ├── Zone.java │ │ ├── ZoneForm.java │ │ ├── ZoneRepository.java │ │ └── ZoneService.java └── resources │ ├── application-dev.properties │ ├── application.properties │ ├── db │ └── migration │ │ ├── V202003162028__Generate_Schema.sql │ │ └── V202003210920__Add_online_zone.sql │ ├── static │ ├── images │ │ ├── default_banner.png │ │ ├── logo_kr_horizontal.png │ │ └── logo_symbol.png │ └── package.json │ ├── templates │ ├── account │ │ ├── check-email.html │ │ ├── check-login-email.html │ │ ├── checked-email.html │ │ ├── email-login.html │ │ ├── logged-in-by-email.html │ │ ├── profile.html │ │ └── sign-up.html │ ├── error.html │ ├── event │ │ ├── form.html │ │ ├── update-form.html │ │ └── view.html │ ├── fragments.html │ ├── index-after-login.html │ ├── index.html │ ├── login.html │ ├── mail │ │ └── simple-link.html │ ├── notification │ │ └── list.html │ ├── search.html │ ├── settings │ │ ├── account.html │ │ ├── notifications.html │ │ ├── password.html │ │ ├── profile.html │ │ ├── tags.html │ │ └── zones.html │ └── study │ │ ├── events.html │ │ ├── form.html │ │ ├── members.html │ │ ├── settings │ │ ├── banner.html │ │ ├── description.html │ │ ├── study.html │ │ ├── tags.html │ │ └── zones.html │ │ └── view.html │ └── zones_kr.csv └── test ├── java └── com │ └── studyolle │ ├── PackageDependencyTests.java │ ├── infra │ ├── ContainerBaseTest.java │ └── MockMvcTest.java │ └── modules │ ├── account │ ├── AccountControllerTest.java │ ├── AccountFactory.java │ ├── SettingsControllerTest.java │ ├── WithAccount.java │ └── WithAccountSecurityContextFacotry.java │ ├── event │ └── EventControllerTest.java │ ├── main │ └── MainControllerTest.java │ ├── study │ ├── StudyControllerTest.java │ ├── StudyFactory.java │ ├── StudySettingsControllerTest.java │ └── StudyTest.java │ └── tag │ └── TagRepositoryTest.java └── resources └── application-test.properties /.github/workflows/maven.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Java project with Maven 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven 3 | 4 | name: Java CI with Maven 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up JDK 11 20 | uses: actions/setup-java@v1 21 | with: 22 | java-version: 11.0.8 23 | architecture: x64 24 | - name: Cache Maven packages 25 | uses: actions/cache@v2 26 | with: 27 | path: ~/.m2 28 | key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} 29 | restore-keys: ${{ runner.os }}-m2 30 | - name: Build with Maven 31 | run: mvn -B package --file pom.xml 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | target/ 3 | !.mvn/wrapper/maven-wrapper.jar 4 | !**/src/main/** 5 | !**/src/test/** 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | 16 | ### IntelliJ IDEA ### 17 | .idea 18 | *.iws 19 | *.iml 20 | *.ipr 21 | 22 | ### NetBeans ### 23 | /nbproject/private/ 24 | /nbbuild/ 25 | /dist/ 26 | /nbdist/ 27 | /.nb-gradle/ 28 | build/ 29 | 30 | ### VS Code ### 31 | .vscode/ 32 | 33 | ### NPM ### 34 | src/main/resources/static/node_modules 35 | src/main/resources/static/node 36 | src/main/resources/static/package-lock.json 37 | 38 | ### Config ### 39 | config 40 | 41 | ### gradle ### 42 | .gradle/** 43 | gradlew* 44 | gradle/** 45 | 46 | ### node ### 47 | yarn.lock 48 | 49 | 50 | -------------------------------------------------------------------------------- /.mvn/wrapper/MavenWrapperDownloader.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2007-present the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import java.net.*; 18 | import java.io.*; 19 | import java.nio.channels.*; 20 | import java.util.Properties; 21 | 22 | public class MavenWrapperDownloader { 23 | 24 | private static final String WRAPPER_VERSION = "0.5.6"; 25 | /** 26 | * Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided. 27 | */ 28 | private static final String DEFAULT_DOWNLOAD_URL = "https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/" 29 | + WRAPPER_VERSION + "/maven-wrapper-" + WRAPPER_VERSION + ".jar"; 30 | 31 | /** 32 | * Path to the maven-wrapper.properties file, which might contain a downloadUrl property to 33 | * use instead of the default one. 34 | */ 35 | private static final String MAVEN_WRAPPER_PROPERTIES_PATH = 36 | ".mvn/wrapper/maven-wrapper.properties"; 37 | 38 | /** 39 | * Path where the maven-wrapper.jar will be saved to. 40 | */ 41 | private static final String MAVEN_WRAPPER_JAR_PATH = 42 | ".mvn/wrapper/maven-wrapper.jar"; 43 | 44 | /** 45 | * Name of the property which should be used to override the default download url for the wrapper. 46 | */ 47 | private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl"; 48 | 49 | public static void main(String args[]) { 50 | System.out.println("- Downloader started"); 51 | File baseDirectory = new File(args[0]); 52 | System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath()); 53 | 54 | // If the maven-wrapper.properties exists, read it and check if it contains a custom 55 | // wrapperUrl parameter. 56 | File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH); 57 | String url = DEFAULT_DOWNLOAD_URL; 58 | if (mavenWrapperPropertyFile.exists()) { 59 | FileInputStream mavenWrapperPropertyFileInputStream = null; 60 | try { 61 | mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile); 62 | Properties mavenWrapperProperties = new Properties(); 63 | mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream); 64 | url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url); 65 | } catch (IOException e) { 66 | System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'"); 67 | } finally { 68 | try { 69 | if (mavenWrapperPropertyFileInputStream != null) { 70 | mavenWrapperPropertyFileInputStream.close(); 71 | } 72 | } catch (IOException e) { 73 | // Ignore ... 74 | } 75 | } 76 | } 77 | System.out.println("- Downloading from: " + url); 78 | 79 | File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH); 80 | if (!outputFile.getParentFile().exists()) { 81 | if (!outputFile.getParentFile().mkdirs()) { 82 | System.out.println( 83 | "- ERROR creating output directory '" + outputFile.getParentFile().getAbsolutePath() + "'"); 84 | } 85 | } 86 | System.out.println("- Downloading to: " + outputFile.getAbsolutePath()); 87 | try { 88 | downloadFileFromURL(url, outputFile); 89 | System.out.println("Done"); 90 | System.exit(0); 91 | } catch (Throwable e) { 92 | System.out.println("- Error downloading"); 93 | e.printStackTrace(); 94 | System.exit(1); 95 | } 96 | } 97 | 98 | private static void downloadFileFromURL(String urlString, File destination) throws Exception { 99 | if (System.getenv("MVNW_USERNAME") != null && System.getenv("MVNW_PASSWORD") != null) { 100 | String username = System.getenv("MVNW_USERNAME"); 101 | char[] password = System.getenv("MVNW_PASSWORD").toCharArray(); 102 | Authenticator.setDefault(new Authenticator() { 103 | @Override 104 | protected PasswordAuthentication getPasswordAuthentication() { 105 | return new PasswordAuthentication(username, password); 106 | } 107 | }); 108 | } 109 | URL website = new URL(urlString); 110 | ReadableByteChannel rbc; 111 | rbc = Channels.newChannel(website.openStream()); 112 | FileOutputStream fos = new FileOutputStream(destination); 113 | fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); 114 | fos.close(); 115 | rbc.close(); 116 | } 117 | 118 | } 119 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackrslab/studyolle/7c21c5621834dd740fd7edbde46bd407031edf9e/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.6.3/apache-maven-3.6.3-bin.zip 2 | wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # StudyOlle 2 | 3 | 스터디 모임 관리 서비스 4 | 5 | # 실행 방법 6 | 7 | ## IDE에서 실행 방법 8 | 9 | IDE에서 프로젝트로 로딩한 다음에 메이븐으로 컴파일 빌드를 하고 App.java 클래스를 실행합니다. 10 | 11 | ### 메이븐으로 컴파일 빌드 하는 방법 12 | 13 | 메이븐이 설치되어 있지 않은 경우 메이븐 랩퍼(mvnw 또는 mvnw.cmd(윈도)를 사용해서 빌드하세요. 14 | 15 | ``` 16 | mvnw compile 17 | ``` 18 | 19 | 메이븐으로 컴파일을 해야 프론트엔드 라이브러리를 받아오며 QueryDSL 관련 코드를 생성합니다. 20 | 21 | ## 콘솔에서 실행 방법 22 | 23 | JAR 패키징을 한 뒤 java -jar로 실행합니다. 24 | 25 | ``` 26 | mvnw clean compile package 27 | ``` 28 | 29 | ``` 30 | java -jar target/*.jar 31 | ``` 32 | 33 | # DB 설정 34 | 35 | PostgreSQL 설치 후, psql로 접속해서 아래 명령어 사용하여 DB와 USER 생성하고 권한 설정. 36 | 37 | ```sql 38 | CREATE DATABASE testdb; 39 | CREATE USER testuser WITH ENCRYPTED PASSWORD 'testpass'; 40 | GRANT ALL PRIVILEGES ON DATABASE testdb TO testuser; 41 | ``` 42 | -------------------------------------------------------------------------------- /_build.gradle_: -------------------------------------------------------------------------------- 1 | /* 2 | step 1 : Tasks - application - npm-Install ( node, npm / needs to install using before. ) 3 | step 2 : Tasks - application - bootRun or any type of running u want. 4 | */ 5 | 6 | plugins { 7 | id 'org.springframework.boot' version '2.4.5' 8 | id 'io.spring.dependency-management' version '1.0.11.RELEASE' 9 | id 'java' 10 | id "com.moowork.node" version "1.3.1" // npm plugin 11 | } 12 | 13 | repositories { 14 | mavenCentral() 15 | gradlePluginPortal() // https://plugins.gradle.org/m2/ 16 | } 17 | 18 | group = 'com.studyolle' 19 | version = '0.0.1-SNAPSHOT' 20 | sourceCompatibility = JavaVersion.VERSION_11 21 | 22 | dependencies { 23 | 24 | implementation 'org.springframework.boot:spring-boot-starter-data-jpa' // chk 25 | implementation 'org.springframework.boot:spring-boot-starter-validation' // chk, req 26 | implementation 'org.springframework.boot:spring-boot-starter-data-rest' // 27 | implementation 'org.springframework.boot:spring-boot-starter-mail' // chk 28 | implementation 'org.springframework.boot:spring-boot-starter-web' // chk 29 | // implementation 'org.springframework.boot:spring-boot-starter-hateoas' // 30 | 31 | implementation 'org.springframework.boot:spring-boot-starter-security' // chk∂ 32 | implementation 'org.springframework.security:spring-security-test' // chk , req for testing. 33 | 34 | implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' // chk 35 | implementation group: 'org.thymeleaf.extras', name: 'thymeleaf-extras-springsecurity5', version: '3.0.4.RELEASE' 36 | // chk, req for spring 5 security 37 | implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:2.5.3' // chk, req 38 | 39 | // lombok 40 | compile group: 'org.projectlombok', name: 'lombok' 41 | annotationProcessor 'org.projectlombok:lombok' // lombok 42 | 43 | implementation group: 'org.modelmapper', name: 'modelmapper', version: '2.4.2' 44 | 45 | // db 46 | developmentOnly 'org.springframework.boot:spring-boot-devtools' // devtools 이용시 h2 console 이용 47 | runtimeOnly 'com.h2database:h2' 48 | runtimeOnly 'mysql:mysql-connector-java' 49 | runtimeOnly 'org.postgresql:postgresql' 50 | 51 | annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' 52 | testImplementation('org.springframework.boot:spring-boot-starter-test') { 53 | exclude group: 'org.junit.vintage', module: 'junit-vintage-engine' 54 | } 55 | 56 | // query dsl 57 | implementation 'com.querydsl:querydsl-core:4.4.0' 58 | implementation 'com.querydsl:querydsl-jpa:4.4.0' 59 | annotationProcessor 'com.querydsl:querydsl-apt:4.1.4:jpa' // querydsl JPAAnnotationProcessor 60 | // querydsl JPAAnnotationProcessor을 사용 61 | annotationProcessor 'jakarta.persistence:jakarta.persistence-api' 62 | annotationProcessor 'jakarta.annotation:jakarta.annotation-api' 63 | 64 | // npm 65 | implementation "com.moowork.gradle:gradle-node-plugin:1.3.1" 66 | 67 | // test 68 | testImplementation 'org.springframework.boot:spring-boot-starter-test' // chk 69 | testCompile group: 'org.projectlombok', name: 'lombok' 70 | testAnnotationProcessor 'org.projectlombok:lombok' // lombok 71 | testImplementation group: 'org.testcontainers', name: 'postgresql', version: '1.15.3' 72 | testImplementation group: 'com.tngtech.archunit', name: 'archunit-junit5-api', version: '0.18.0' 73 | testImplementation group: 'org.testcontainers', name: 'junit-jupiter', version: '1.15.3' 74 | 75 | } 76 | 77 | def generated = 'generated' 78 | 79 | sourceSets { 80 | main { 81 | java { 82 | srcDirs += generated 83 | //exclude '**/uncompilable/**' 84 | } 85 | 86 | } 87 | } 88 | 89 | configurations { 90 | compileOnly { 91 | extendsFrom annotationProcessor 92 | } 93 | } 94 | 95 | apply plugin: "com.moowork.node" 96 | // npm using on gradle project 97 | tasks.register("npm-Install") { 98 | group = "application" 99 | description = "Installs dependencies from package.json" 100 | tasks.appNpmInstall.exec(); 101 | } 102 | 103 | task appNpmInstall(type: NpmTask) { 104 | // src/main/resources/static 105 | description = "Installs dependencies from package.json" 106 | workingDir = file("/src/main/resources/static") 107 | args = ['install'] 108 | } 109 | 110 | test { 111 | useJUnitPlatform() 112 | } 113 | 114 | clean.doLast { 115 | file(generated).deleteDir() 116 | } 117 | -------------------------------------------------------------------------------- /mvnw.cmd: -------------------------------------------------------------------------------- 1 | @REM ---------------------------------------------------------------------------- 2 | @REM Licensed to the Apache Software Foundation (ASF) under one 3 | @REM or more contributor license agreements. See the NOTICE file 4 | @REM distributed with this work for additional information 5 | @REM regarding copyright ownership. The ASF licenses this file 6 | @REM to you under the Apache License, Version 2.0 (the 7 | @REM "License"); you may not use this file except in compliance 8 | @REM with the License. You may obtain a copy of the License at 9 | @REM 10 | @REM https://www.apache.org/licenses/LICENSE-2.0 11 | @REM 12 | @REM Unless required by applicable law or agreed to in writing, 13 | @REM software distributed under the License is distributed on an 14 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | @REM KIND, either express or implied. See the License for the 16 | @REM specific language governing permissions and limitations 17 | @REM under the License. 18 | @REM ---------------------------------------------------------------------------- 19 | 20 | @REM ---------------------------------------------------------------------------- 21 | @REM Maven Start Up Batch script 22 | @REM 23 | @REM Required ENV vars: 24 | @REM JAVA_HOME - location of a JDK home dir 25 | @REM 26 | @REM Optional ENV vars 27 | @REM M2_HOME - location of maven2's installed home dir 28 | @REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands 29 | @REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending 30 | @REM MAVEN_OPTS - parameters passed to the Java VM when running Maven 31 | @REM e.g. to debug Maven itself, use 32 | @REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 33 | @REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files 34 | @REM ---------------------------------------------------------------------------- 35 | 36 | @REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' 37 | @echo off 38 | @REM set title of command window 39 | title %0 40 | @REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' 41 | @if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% 42 | 43 | @REM set %HOME% to equivalent of $HOME 44 | if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") 45 | 46 | @REM Execute a user defined script before this one 47 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre 48 | @REM check for pre script, once with legacy .bat ending and once with .cmd ending 49 | if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat" 50 | if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd" 51 | :skipRcPre 52 | 53 | @setlocal 54 | 55 | set ERROR_CODE=0 56 | 57 | @REM To isolate internal variables from possible post scripts, we use another setlocal 58 | @setlocal 59 | 60 | @REM ==== START VALIDATION ==== 61 | if not "%JAVA_HOME%" == "" goto OkJHome 62 | 63 | echo. 64 | echo Error: JAVA_HOME not found in your environment. >&2 65 | echo Please set the JAVA_HOME variable in your environment to match the >&2 66 | echo location of your Java installation. >&2 67 | echo. 68 | goto error 69 | 70 | :OkJHome 71 | if exist "%JAVA_HOME%\bin\java.exe" goto init 72 | 73 | echo. 74 | echo Error: JAVA_HOME is set to an invalid directory. >&2 75 | echo JAVA_HOME = "%JAVA_HOME%" >&2 76 | echo Please set the JAVA_HOME variable in your environment to match the >&2 77 | echo location of your Java installation. >&2 78 | echo. 79 | goto error 80 | 81 | @REM ==== END VALIDATION ==== 82 | 83 | :init 84 | 85 | @REM Find the project base dir, i.e. the directory that contains the folder ".mvn". 86 | @REM Fallback to current working directory if not found. 87 | 88 | set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% 89 | IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir 90 | 91 | set EXEC_DIR=%CD% 92 | set WDIR=%EXEC_DIR% 93 | :findBaseDir 94 | IF EXIST "%WDIR%"\.mvn goto baseDirFound 95 | cd .. 96 | IF "%WDIR%"=="%CD%" goto baseDirNotFound 97 | set WDIR=%CD% 98 | goto findBaseDir 99 | 100 | :baseDirFound 101 | set MAVEN_PROJECTBASEDIR=%WDIR% 102 | cd "%EXEC_DIR%" 103 | goto endDetectBaseDir 104 | 105 | :baseDirNotFound 106 | set MAVEN_PROJECTBASEDIR=%EXEC_DIR% 107 | cd "%EXEC_DIR%" 108 | 109 | :endDetectBaseDir 110 | 111 | IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig 112 | 113 | @setlocal EnableExtensions EnableDelayedExpansion 114 | for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a 115 | @endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% 116 | 117 | :endReadAdditionalConfig 118 | 119 | SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" 120 | set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" 121 | set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 122 | 123 | set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" 124 | 125 | FOR /F "tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( 126 | IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B 127 | ) 128 | 129 | @REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 130 | @REM This allows using the maven wrapper in projects that prohibit checking in binary data. 131 | if exist %WRAPPER_JAR% ( 132 | if "%MVNW_VERBOSE%" == "true" ( 133 | echo Found %WRAPPER_JAR% 134 | ) 135 | ) else ( 136 | if not "%MVNW_REPOURL%" == "" ( 137 | SET DOWNLOAD_URL="%MVNW_REPOURL%/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" 138 | ) 139 | if "%MVNW_VERBOSE%" == "true" ( 140 | echo Couldn't find %WRAPPER_JAR%, downloading it ... 141 | echo Downloading from: %DOWNLOAD_URL% 142 | ) 143 | 144 | powershell -Command "&{"^ 145 | "$webclient = new-object System.Net.WebClient;"^ 146 | "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ 147 | "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ 148 | "}"^ 149 | "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ 150 | "}" 151 | if "%MVNW_VERBOSE%" == "true" ( 152 | echo Finished downloading %WRAPPER_JAR% 153 | ) 154 | ) 155 | @REM End of extension 156 | 157 | @REM Provide a "standardized" way to retrieve the CLI args that will 158 | @REM work with both Windows and non-Windows executions. 159 | set MAVEN_CMD_LINE_ARGS=%* 160 | 161 | %MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* 162 | if ERRORLEVEL 1 goto error 163 | goto end 164 | 165 | :error 166 | set ERROR_CODE=1 167 | 168 | :end 169 | @endlocal & set ERROR_CODE=%ERROR_CODE% 170 | 171 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost 172 | @REM check for post script, once with legacy .bat ending and once with .cmd ending 173 | if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat" 174 | if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd" 175 | :skipRcPost 176 | 177 | @REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' 178 | if "%MAVEN_BATCH_PAUSE%" == "on" pause 179 | 180 | if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE% 181 | 182 | exit /B %ERROR_CODE% 183 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/App.java: -------------------------------------------------------------------------------- 1 | package com.studyolle; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication(proxyBeanMethods = false) 7 | public class App { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(App.class, args); 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/infra/config/AppConfig.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.infra.config; 2 | 3 | import org.modelmapper.ModelMapper; 4 | import org.modelmapper.convention.NameTokenizers; 5 | import org.springframework.context.annotation.Bean; 6 | import org.springframework.context.annotation.Configuration; 7 | import org.springframework.security.crypto.factory.PasswordEncoderFactories; 8 | import org.springframework.security.crypto.password.PasswordEncoder; 9 | 10 | @Configuration(proxyBeanMethods = false) 11 | public class AppConfig { 12 | 13 | @Bean 14 | public PasswordEncoder passwordEncoder() { 15 | return PasswordEncoderFactories.createDelegatingPasswordEncoder(); 16 | } 17 | 18 | @Bean 19 | public ModelMapper modelMapper() { 20 | ModelMapper modelMapper = new ModelMapper(); 21 | modelMapper.getConfiguration() 22 | .setDestinationNameTokenizer(NameTokenizers.UNDERSCORE) 23 | .setSourceNameTokenizer(NameTokenizers.UNDERSCORE); 24 | return modelMapper; 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/infra/config/AppProperties.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.infra.config; 2 | 3 | import lombok.Data; 4 | import org.springframework.boot.context.properties.ConfigurationProperties; 5 | import org.springframework.stereotype.Component; 6 | 7 | @Data 8 | @Component 9 | @ConfigurationProperties("app") 10 | public class AppProperties { 11 | 12 | private String host; 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/infra/config/AsyncConfig.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.infra.config; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.scheduling.annotation.AsyncConfigurer; 6 | import org.springframework.scheduling.annotation.EnableAsync; 7 | import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; 8 | 9 | import java.util.concurrent.Executor; 10 | 11 | @Slf4j 12 | @Configuration(proxyBeanMethods = false) 13 | @EnableAsync 14 | public class AsyncConfig implements AsyncConfigurer { 15 | 16 | @Override 17 | public Executor getAsyncExecutor() { 18 | ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); 19 | int processors = Runtime.getRuntime().availableProcessors(); 20 | log.info("processors count {}", processors); 21 | executor.setCorePoolSize(processors); 22 | executor.setMaxPoolSize(processors * 2); 23 | executor.setQueueCapacity(50); 24 | executor.setKeepAliveSeconds(60); 25 | executor.setThreadNamePrefix("AsyncExecutor-"); 26 | executor.initialize(); 27 | return executor; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/infra/config/SecurityConfig.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.infra.config; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import org.springframework.boot.autoconfigure.security.servlet.PathRequest; 5 | import org.springframework.context.annotation.Bean; 6 | import org.springframework.context.annotation.Configuration; 7 | import org.springframework.http.HttpMethod; 8 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 9 | import org.springframework.security.config.annotation.web.builders.WebSecurity; 10 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 11 | import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; 12 | import org.springframework.security.core.userdetails.UserDetailsService; 13 | import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl; 14 | import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; 15 | 16 | import javax.sql.DataSource; 17 | 18 | @Configuration(proxyBeanMethods = false) 19 | @EnableWebSecurity 20 | @RequiredArgsConstructor 21 | public class SecurityConfig extends WebSecurityConfigurerAdapter { 22 | 23 | private final UserDetailsService userDetailsService; 24 | private final DataSource dataSource; 25 | 26 | @Override 27 | protected void configure(HttpSecurity http) throws Exception { 28 | http.authorizeRequests() 29 | .mvcMatchers("/login").not().fullyAuthenticated() 30 | .mvcMatchers("/", "/sign-up", "/check-email-token", 31 | "/email-login", "/login-by-email", "/search/study").permitAll() 32 | .mvcMatchers(HttpMethod.GET, "/profile/*").permitAll() 33 | .anyRequest().authenticated(); 34 | 35 | http.formLogin() 36 | .loginPage("/login"); 37 | 38 | http.logout() 39 | .logoutSuccessUrl("/"); 40 | 41 | http.rememberMe() 42 | .userDetailsService(userDetailsService) 43 | .tokenRepository(tokenRepository()); 44 | } 45 | 46 | @Bean 47 | public PersistentTokenRepository tokenRepository() { 48 | JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl(); 49 | jdbcTokenRepository.setDataSource(dataSource); 50 | return jdbcTokenRepository; 51 | } 52 | 53 | @Override 54 | public void configure(WebSecurity web) throws Exception { 55 | web.ignoring() 56 | .mvcMatchers("/node_modules/**") 57 | .requestMatchers(PathRequest.toStaticResources().atCommonLocations()); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/infra/config/WebConfig.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.infra.config; 2 | 3 | import com.studyolle.modules.notification.NotificationInterceptor; 4 | import lombok.RequiredArgsConstructor; 5 | import org.springframework.boot.autoconfigure.security.StaticResourceLocation; 6 | import org.springframework.context.annotation.Configuration; 7 | import org.springframework.web.servlet.config.annotation.InterceptorRegistry; 8 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 9 | 10 | import java.util.Arrays; 11 | import java.util.List; 12 | import java.util.stream.Collectors; 13 | 14 | @Configuration(proxyBeanMethods = false) 15 | @RequiredArgsConstructor 16 | public class WebConfig implements WebMvcConfigurer { 17 | 18 | private final NotificationInterceptor notificationInterceptor; 19 | 20 | @Override 21 | public void addInterceptors(InterceptorRegistry registry) { 22 | List staticResourcesPath = Arrays.stream(StaticResourceLocation.values()) 23 | .flatMap(StaticResourceLocation::getPatterns) 24 | .collect(Collectors.toList()); 25 | staticResourcesPath.add("/node_modules/**"); 26 | 27 | registry.addInterceptor(notificationInterceptor) 28 | .excludePathPatterns(staticResourcesPath); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/infra/mail/ConsoleEmailService.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.infra.mail; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import org.springframework.context.annotation.Profile; 5 | import org.springframework.stereotype.Component; 6 | 7 | @Slf4j 8 | @Profile({"local", "test"}) 9 | @Component 10 | public class ConsoleEmailService implements EmailService{ 11 | 12 | @Override 13 | public void sendEmail(EmailMessage emailMessage) { 14 | log.info("sent email: {}", emailMessage.getMessage()); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/infra/mail/EmailMessage.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.infra.mail; 2 | 3 | import lombok.Builder; 4 | import lombok.Data; 5 | 6 | @Data 7 | @Builder 8 | public class EmailMessage { 9 | 10 | private String to; 11 | 12 | private String subject; 13 | 14 | private String message; 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/infra/mail/EmailService.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.infra.mail; 2 | 3 | public interface EmailService { 4 | 5 | void sendEmail(EmailMessage emailMessage); 6 | } 7 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/infra/mail/HtmlEmailService.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.infra.mail; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import lombok.extern.slf4j.Slf4j; 5 | import org.springframework.context.annotation.Profile; 6 | import org.springframework.mail.javamail.JavaMailSender; 7 | import org.springframework.mail.javamail.MimeMessageHelper; 8 | import org.springframework.stereotype.Component; 9 | 10 | import javax.mail.MessagingException; 11 | import javax.mail.internet.MimeMessage; 12 | 13 | @Slf4j 14 | @Profile("dev") 15 | @Component 16 | @RequiredArgsConstructor 17 | public class HtmlEmailService implements EmailService { 18 | 19 | private final JavaMailSender javaMailSender; 20 | 21 | @Override 22 | public void sendEmail(EmailMessage emailMessage) { 23 | MimeMessage mimeMessage = javaMailSender.createMimeMessage(); 24 | try { 25 | MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage, false, "UTF-8"); 26 | mimeMessageHelper.setTo(emailMessage.getTo()); 27 | mimeMessageHelper.setSubject(emailMessage.getSubject()); 28 | mimeMessageHelper.setText(emailMessage.getMessage(), true); 29 | javaMailSender.send(mimeMessage); 30 | log.info("sent email: {}", emailMessage.getMessage()); 31 | } catch (MessagingException e) { 32 | log.error("failed to send email", e); 33 | throw new RuntimeException(e); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/account/Account.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.account; 2 | 3 | import com.studyolle.modules.tag.Tag; 4 | import com.studyolle.modules.zone.Zone; 5 | import lombok.*; 6 | 7 | import javax.persistence.*; 8 | import java.time.LocalDateTime; 9 | import java.util.HashSet; 10 | import java.util.Set; 11 | import java.util.UUID; 12 | 13 | @Entity 14 | @Getter @Setter @EqualsAndHashCode(of = "id") 15 | @Builder @AllArgsConstructor @NoArgsConstructor 16 | public class Account { 17 | 18 | @Id @GeneratedValue 19 | private Long id; 20 | 21 | @Column(unique = true) 22 | private String email; 23 | 24 | @Column(unique = true) 25 | private String nickname; 26 | 27 | private String password; 28 | 29 | private boolean emailVerified; 30 | 31 | private String emailCheckToken; 32 | 33 | private LocalDateTime emailCheckTokenGeneratedAt; 34 | 35 | private LocalDateTime joinedAt; 36 | 37 | private String bio; 38 | 39 | private String url; 40 | 41 | private String occupation; 42 | 43 | private String location; 44 | 45 | @Lob @Basic(fetch = FetchType.EAGER) 46 | private String profileImage; 47 | 48 | private boolean studyCreatedByEmail; 49 | 50 | private boolean studyCreatedByWeb = true; 51 | 52 | private boolean studyEnrollmentResultByEmail; 53 | 54 | private boolean studyEnrollmentResultByWeb = true; 55 | 56 | private boolean studyUpdatedByEmail; 57 | 58 | private boolean studyUpdatedByWeb = true; 59 | 60 | @ManyToMany 61 | private Set tags = new HashSet<>(); 62 | 63 | @ManyToMany 64 | private Set zones = new HashSet<>(); 65 | 66 | public void generateEmailCheckToken() { 67 | this.emailCheckToken = UUID.randomUUID().toString(); 68 | this.emailCheckTokenGeneratedAt = LocalDateTime.now(); 69 | } 70 | 71 | public void completeSignUp() { 72 | this.emailVerified = true; 73 | this.joinedAt = LocalDateTime.now(); 74 | } 75 | 76 | public boolean isValidToken(String token) { 77 | return this.emailCheckToken.equals(token); 78 | } 79 | 80 | public boolean canSendConfirmEmail() { 81 | return this.emailCheckTokenGeneratedAt.isBefore(LocalDateTime.now().minusHours(1)); 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/account/AccountController.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.account; 2 | 3 | import com.studyolle.modules.account.form.SignUpForm; 4 | import com.studyolle.modules.account.validator.SignUpFormValidator; 5 | import lombok.RequiredArgsConstructor; 6 | import org.springframework.stereotype.Controller; 7 | import org.springframework.ui.Model; 8 | import org.springframework.validation.Errors; 9 | import org.springframework.web.bind.WebDataBinder; 10 | import org.springframework.web.bind.annotation.GetMapping; 11 | import org.springframework.web.bind.annotation.InitBinder; 12 | import org.springframework.web.bind.annotation.PathVariable; 13 | import org.springframework.web.bind.annotation.PostMapping; 14 | import org.springframework.web.servlet.mvc.support.RedirectAttributes; 15 | 16 | import javax.validation.Valid; 17 | 18 | @Controller 19 | @RequiredArgsConstructor 20 | public class AccountController { 21 | 22 | private final SignUpFormValidator signUpFormValidator; 23 | private final AccountService accountService; 24 | private final AccountRepository accountRepository; 25 | 26 | @InitBinder("signUpForm") 27 | public void initBinder(WebDataBinder webDataBinder) { 28 | webDataBinder.addValidators(signUpFormValidator); 29 | } 30 | 31 | @GetMapping("/sign-up") 32 | public String signUpForm(Model model) { 33 | model.addAttribute(new SignUpForm()); 34 | return "account/sign-up"; 35 | } 36 | 37 | @PostMapping("/sign-up") 38 | public String signUpSubmit(@Valid SignUpForm signUpForm, Errors errors) { 39 | if (errors.hasErrors()) { 40 | return "account/sign-up"; 41 | } 42 | 43 | Account account = accountService.processNewAccount(signUpForm); 44 | accountService.login(account); 45 | return "redirect:/"; 46 | } 47 | 48 | @GetMapping("/check-email-token") 49 | public String checkEmailToken(String token, String email, Model model) { 50 | Account account = accountRepository.findByEmail(email); 51 | String view = "account/checked-email"; 52 | if (account == null) { 53 | model.addAttribute("error", "wrong.email"); 54 | return view; 55 | } 56 | 57 | if (!account.isValidToken(token)) { 58 | model.addAttribute("error", "wrong.token"); 59 | return view; 60 | } 61 | 62 | accountService.completeSignUp(account); 63 | model.addAttribute("numberOfUser", accountRepository.count()); 64 | model.addAttribute("nickname", account.getNickname()); 65 | return view; 66 | } 67 | 68 | @GetMapping("/check-email") 69 | public String checkEmail(@CurrentAccount Account account, Model model) { 70 | model.addAttribute("email", account.getEmail()); 71 | return "account/check-email"; 72 | } 73 | 74 | @GetMapping("/resend-confirm-email") 75 | public String resendConfirmEmail(@CurrentAccount Account account, Model model) { 76 | if (!account.canSendConfirmEmail()) { 77 | model.addAttribute("error", "인증 이메일은 1시간에 한번만 전송할 수 있습니다."); 78 | model.addAttribute("email", account.getEmail()); 79 | return "account/check-email"; 80 | } 81 | 82 | accountService.sendSignUpConfirmEmail(account); 83 | return "redirect:/"; 84 | } 85 | 86 | @GetMapping("/profile/{nickname}") 87 | public String viewProfile(@PathVariable String nickname, Model model, @CurrentAccount Account account) { 88 | Account accountToView = accountService.getAccount(nickname); 89 | model.addAttribute(accountToView); 90 | model.addAttribute("isOwner", accountToView.equals(account)); 91 | return "account/profile"; 92 | } 93 | 94 | @GetMapping("/email-login") 95 | public String emailLoginForm() { 96 | return "account/email-login"; 97 | } 98 | 99 | @PostMapping("/email-login") 100 | public String sendEmailLoginLink(String email, Model model, RedirectAttributes attributes) { 101 | Account account = accountRepository.findByEmail(email); 102 | if (account == null) { 103 | model.addAttribute("error", "유효한 이메일 주소가 아닙니다."); 104 | return "account/email-login"; 105 | } 106 | 107 | if (!account.canSendConfirmEmail()) { 108 | model.addAttribute("error", "이메일 로그인은 1시간 뒤에 사용할 수 있습니다."); 109 | return "account/email-login"; 110 | } 111 | 112 | accountService.sendLoginLink(account); 113 | attributes.addFlashAttribute("message", "이메일 인증 메일을 발송했습니다."); 114 | return "redirect:/email-login"; 115 | } 116 | 117 | @GetMapping("/login-by-email") 118 | public String loginByEmail(String token, String email, Model model) { 119 | Account account = accountRepository.findByEmail(email); 120 | String view = "account/logged-in-by-email"; 121 | if (account == null || !account.isValidToken(token)) { 122 | model.addAttribute("error", "로그인할 수 없습니다."); 123 | return view; 124 | } 125 | 126 | accountService.login(account); 127 | return view; 128 | } 129 | 130 | } 131 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/account/AccountPredicates.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.account; 2 | 3 | import com.querydsl.core.types.Predicate; 4 | import com.studyolle.modules.tag.Tag; 5 | import com.studyolle.modules.zone.Zone; 6 | 7 | import java.util.Set; 8 | 9 | 10 | public class AccountPredicates { 11 | 12 | public static Predicate findByTagsAndZones(Set tags, Set zones) { 13 | QAccount account = QAccount.account; 14 | return account.zones.any().in(zones).and(account.tags.any().in(tags)); 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/account/AccountRepository.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.account; 2 | 3 | import org.springframework.data.jpa.repository.EntityGraph; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | import org.springframework.data.querydsl.QuerydslPredicateExecutor; 6 | import org.springframework.transaction.annotation.Transactional; 7 | 8 | @Transactional(readOnly = true) 9 | public interface AccountRepository extends JpaRepository, QuerydslPredicateExecutor { 10 | 11 | boolean existsByEmail(String email); 12 | 13 | boolean existsByNickname(String nickname); 14 | 15 | Account findByEmail(String email); 16 | 17 | Account findByNickname(String nickname); 18 | 19 | @EntityGraph(attributePaths = {"tags", "zones"}) 20 | Account findAccountWithTagsAndZonesById(Long id); 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/account/CurrentAccount.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.account; 2 | 3 | import org.springframework.security.core.annotation.AuthenticationPrincipal; 4 | 5 | import java.lang.annotation.ElementType; 6 | import java.lang.annotation.Retention; 7 | import java.lang.annotation.RetentionPolicy; 8 | import java.lang.annotation.Target; 9 | 10 | @Retention(RetentionPolicy.RUNTIME) 11 | @Target(ElementType.PARAMETER) 12 | @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : account") 13 | public @interface CurrentAccount { 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/account/PersistentLogins.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.account; 2 | 3 | import lombok.Getter; 4 | import lombok.Setter; 5 | 6 | import javax.persistence.Column; 7 | import javax.persistence.Entity; 8 | import javax.persistence.Id; 9 | import javax.persistence.Table; 10 | import java.time.LocalDateTime; 11 | 12 | @Table(name = "persistent_logins") 13 | @Entity 14 | @Getter @Setter 15 | public class PersistentLogins { 16 | 17 | @Id 18 | @Column(length = 64) 19 | private String series; 20 | 21 | @Column(nullable = false, length = 64) 22 | private String username; 23 | 24 | @Column(nullable = false, length = 64) 25 | private String token; 26 | 27 | @Column(name = "last_used", nullable = false, length = 64) 28 | private LocalDateTime lastUsed; 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/account/UserAccount.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.account; 2 | 3 | import lombok.Getter; 4 | import org.springframework.security.core.authority.SimpleGrantedAuthority; 5 | import org.springframework.security.core.userdetails.User; 6 | 7 | import java.util.List; 8 | 9 | @Getter 10 | public class UserAccount extends User { 11 | 12 | private Account account; 13 | 14 | public UserAccount(Account account) { 15 | super(account.getNickname(), account.getPassword(), List.of(new SimpleGrantedAuthority("ROLE_USER"))); 16 | this.account = account; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/account/form/NicknameForm.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.account.form; 2 | 3 | import lombok.Data; 4 | import org.hibernate.validator.constraints.Length; 5 | 6 | import javax.validation.constraints.NotBlank; 7 | import javax.validation.constraints.Pattern; 8 | 9 | @Data 10 | public class NicknameForm { 11 | 12 | @NotBlank 13 | @Length(min = 3, max = 20) 14 | @Pattern(regexp = "^[ㄱ-ㅎ가-힣a-z0-9_-]{3,20}$") 15 | private String nickname; 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/account/form/Notifications.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.account.form; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class Notifications { 7 | 8 | private boolean studyCreatedByEmail; 9 | 10 | private boolean studyCreatedByWeb; 11 | 12 | private boolean studyEnrollmentResultByEmail; 13 | 14 | private boolean studyEnrollmentResultByWeb; 15 | 16 | private boolean studyUpdatedByEmail; 17 | 18 | private boolean studyUpdatedByWeb; 19 | 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/account/form/PasswordForm.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.account.form; 2 | 3 | import lombok.Data; 4 | import org.hibernate.validator.constraints.Length; 5 | 6 | @Data 7 | public class PasswordForm { 8 | 9 | @Length(min = 8, max = 50) 10 | private String newPassword; 11 | 12 | @Length(min = 8, max = 50) 13 | private String newPasswordConfirm; 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/account/form/Profile.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.account.form; 2 | 3 | import lombok.Data; 4 | import org.hibernate.validator.constraints.Length; 5 | 6 | @Data 7 | public class Profile { 8 | 9 | @Length(max = 35) 10 | private String bio; 11 | 12 | @Length(max = 50) 13 | private String url; 14 | 15 | @Length(max = 50) 16 | private String occupation; 17 | 18 | @Length(max = 50) 19 | private String location; 20 | 21 | private String profileImage; 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/account/form/SignUpForm.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.account.form; 2 | 3 | import lombok.Data; 4 | import org.hibernate.validator.constraints.Length; 5 | 6 | import javax.validation.constraints.Email; 7 | import javax.validation.constraints.NotBlank; 8 | import javax.validation.constraints.Pattern; 9 | 10 | @Data 11 | public class SignUpForm { 12 | 13 | @NotBlank 14 | @Length(min = 3, max = 20) 15 | @Pattern(regexp = "^[ㄱ-ㅎ가-힣a-z0-9_-]{3,20}$") 16 | private String nickname; 17 | 18 | @Email 19 | @NotBlank 20 | private String email; 21 | 22 | @NotBlank 23 | @Length(min = 8, max = 50) 24 | private String password; 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/account/validator/NicknameValidator.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.account.validator; 2 | 3 | import com.studyolle.modules.account.Account; 4 | import com.studyolle.modules.account.AccountRepository; 5 | import com.studyolle.modules.account.form.NicknameForm; 6 | import lombok.RequiredArgsConstructor; 7 | import org.springframework.stereotype.Component; 8 | import org.springframework.validation.Errors; 9 | import org.springframework.validation.Validator; 10 | 11 | @Component 12 | @RequiredArgsConstructor 13 | public class NicknameValidator implements Validator { 14 | 15 | private final AccountRepository accountRepository; 16 | 17 | @Override 18 | public boolean supports(Class clazz) { 19 | return NicknameForm.class.isAssignableFrom(clazz); 20 | } 21 | 22 | @Override 23 | public void validate(Object target, Errors errors) { 24 | NicknameForm nicknameForm = (NicknameForm) target; 25 | Account byNickname = accountRepository.findByNickname(nicknameForm.getNickname()); 26 | if (byNickname != null) { 27 | errors.rejectValue("nickname", "wrong.value", "입력하신 닉네임을 사용할 수 없습니다."); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/account/validator/PasswordFormValidator.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.account.validator; 2 | 3 | import com.studyolle.modules.account.form.PasswordForm; 4 | import org.springframework.validation.Errors; 5 | import org.springframework.validation.Validator; 6 | 7 | public class PasswordFormValidator implements Validator { 8 | 9 | @Override 10 | public boolean supports(Class clazz) { 11 | return PasswordForm.class.isAssignableFrom(clazz); 12 | } 13 | 14 | @Override 15 | public void validate(Object target, Errors errors) { 16 | PasswordForm passwordForm = (PasswordForm)target; 17 | if (!passwordForm.getNewPassword().equals(passwordForm.getNewPasswordConfirm())) { 18 | errors.rejectValue("newPassword", "wrong.value", "입력한 새 패스워드가 일치하지 않습니다."); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/account/validator/SignUpFormValidator.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.account.validator; 2 | 3 | import com.studyolle.modules.account.AccountRepository; 4 | import com.studyolle.modules.account.form.SignUpForm; 5 | import lombok.RequiredArgsConstructor; 6 | import org.springframework.stereotype.Component; 7 | import org.springframework.validation.Errors; 8 | import org.springframework.validation.Validator; 9 | 10 | @Component 11 | @RequiredArgsConstructor 12 | public class SignUpFormValidator implements Validator { 13 | 14 | private final AccountRepository accountRepository; 15 | 16 | @Override 17 | public boolean supports(Class aClass) { 18 | return aClass.isAssignableFrom(SignUpForm.class); 19 | } 20 | 21 | @Override 22 | public void validate(Object object, Errors errors) { 23 | SignUpForm signUpForm = (SignUpForm)object; 24 | if (accountRepository.existsByEmail(signUpForm.getEmail())) { 25 | errors.rejectValue("email", "invalid.email", new Object[]{signUpForm.getEmail()}, "이미 사용중인 이메일입니다."); 26 | } 27 | 28 | if (accountRepository.existsByNickname(signUpForm.getNickname())) { 29 | errors.rejectValue("nickname", "invalid.nickname", new Object[]{signUpForm.getEmail()}, "이미 사용중인 닉네임입니다."); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/event/Enrollment.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.event; 2 | 3 | import com.studyolle.modules.account.Account; 4 | import lombok.EqualsAndHashCode; 5 | import lombok.Getter; 6 | import lombok.Setter; 7 | 8 | import javax.persistence.*; 9 | import java.time.LocalDateTime; 10 | 11 | @NamedEntityGraph( 12 | name = "Enrollment.withEventAndStudy", 13 | attributeNodes = { 14 | @NamedAttributeNode(value = "event", subgraph = "study") 15 | }, 16 | subgraphs = @NamedSubgraph(name = "study", attributeNodes = @NamedAttributeNode("study")) 17 | ) 18 | @Entity 19 | @Getter @Setter @EqualsAndHashCode(of = "id") 20 | public class Enrollment { 21 | 22 | @Id @GeneratedValue 23 | private Long id; 24 | 25 | @ManyToOne 26 | private Event event; 27 | 28 | @ManyToOne 29 | private Account account; 30 | 31 | private LocalDateTime enrolledAt; 32 | 33 | private boolean accepted; 34 | 35 | private boolean attended; 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/event/EnrollmentRepository.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.event; 2 | 3 | import com.studyolle.modules.account.Account; 4 | import org.springframework.data.jpa.repository.EntityGraph; 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | import org.springframework.transaction.annotation.Transactional; 7 | 8 | import java.util.List; 9 | 10 | @Transactional(readOnly = true) 11 | public interface EnrollmentRepository extends JpaRepository { 12 | boolean existsByEventAndAccount(Event event, Account account); 13 | 14 | Enrollment findByEventAndAccount(Event event, Account account); 15 | 16 | @EntityGraph("Enrollment.withEventAndStudy") 17 | List findByAccountAndAcceptedOrderByEnrolledAtDesc(Account account, boolean accepted); 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/event/Event.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.event; 2 | 3 | import com.studyolle.modules.account.Account; 4 | import com.studyolle.modules.account.UserAccount; 5 | import com.studyolle.modules.study.Study; 6 | import lombok.EqualsAndHashCode; 7 | import lombok.Getter; 8 | import lombok.Setter; 9 | 10 | import javax.persistence.*; 11 | import java.time.LocalDateTime; 12 | import java.util.ArrayList; 13 | import java.util.List; 14 | import java.util.stream.Collectors; 15 | 16 | @NamedEntityGraph( 17 | name = "Event.withEnrollments", 18 | attributeNodes = @NamedAttributeNode("enrollments") 19 | ) 20 | @Entity 21 | @Getter @Setter @EqualsAndHashCode(of = "id") 22 | public class Event { 23 | 24 | @Id @GeneratedValue 25 | private Long id; 26 | 27 | @ManyToOne 28 | private Study study; 29 | 30 | @ManyToOne 31 | private Account createdBy; 32 | 33 | @Column(nullable = false) 34 | private String title; 35 | 36 | @Lob 37 | private String description; 38 | 39 | @Column(nullable = false) 40 | private LocalDateTime createdDateTime; 41 | 42 | @Column(nullable = false) 43 | private LocalDateTime endEnrollmentDateTime; 44 | 45 | @Column(nullable = false) 46 | private LocalDateTime startDateTime; 47 | 48 | @Column(nullable = false) 49 | private LocalDateTime endDateTime; 50 | 51 | @Column 52 | private Integer limitOfEnrollments; 53 | 54 | @OneToMany(mappedBy = "event") 55 | @OrderBy("enrolledAt") 56 | private List enrollments = new ArrayList<>(); 57 | 58 | @Enumerated(EnumType.STRING) 59 | private EventType eventType; 60 | 61 | public boolean isEnrollableFor(UserAccount userAccount) { 62 | return isNotClosed() && !isAttended(userAccount) && !isAlreadyEnrolled(userAccount); 63 | } 64 | 65 | public boolean isDisenrollableFor(UserAccount userAccount) { 66 | return isNotClosed() && !isAttended(userAccount) && isAlreadyEnrolled(userAccount); 67 | } 68 | 69 | private boolean isNotClosed() { 70 | return this.endEnrollmentDateTime.isAfter(LocalDateTime.now()); 71 | } 72 | 73 | public boolean isAttended(UserAccount userAccount) { 74 | Account account = userAccount.getAccount(); 75 | for (Enrollment e : this.enrollments) { 76 | if (e.getAccount().equals(account) && e.isAttended()) { 77 | return true; 78 | } 79 | } 80 | 81 | return false; 82 | } 83 | 84 | public int numberOfRemainSpots() { 85 | return this.limitOfEnrollments - (int) this.enrollments.stream().filter(Enrollment::isAccepted).count(); 86 | } 87 | 88 | private boolean isAlreadyEnrolled(UserAccount userAccount) { 89 | Account account = userAccount.getAccount(); 90 | for (Enrollment e : this.enrollments) { 91 | if (e.getAccount().equals(account)) { 92 | return true; 93 | } 94 | } 95 | return false; 96 | } 97 | 98 | public long getNumberOfAcceptedEnrollments() { 99 | return this.enrollments.stream().filter(Enrollment::isAccepted).count(); 100 | } 101 | 102 | public void addEnrollment(Enrollment enrollment) { 103 | this.enrollments.add(enrollment); 104 | enrollment.setEvent(this); 105 | } 106 | 107 | public void removeEnrollment(Enrollment enrollment) { 108 | this.enrollments.remove(enrollment); 109 | enrollment.setEvent(null); 110 | } 111 | 112 | public boolean isAbleToAcceptWaitingEnrollment() { 113 | return this.eventType == EventType.FCFS && this.limitOfEnrollments > this.getNumberOfAcceptedEnrollments(); 114 | } 115 | 116 | public boolean canAccept(Enrollment enrollment) { 117 | return this.eventType == EventType.CONFIRMATIVE 118 | && this.enrollments.contains(enrollment) 119 | && this.limitOfEnrollments > this.getNumberOfAcceptedEnrollments() 120 | && !enrollment.isAttended() 121 | && !enrollment.isAccepted(); 122 | } 123 | 124 | public boolean canReject(Enrollment enrollment) { 125 | return this.eventType == EventType.CONFIRMATIVE 126 | && this.enrollments.contains(enrollment) 127 | && !enrollment.isAttended() 128 | && enrollment.isAccepted(); 129 | } 130 | 131 | private List getWaitingList() { 132 | return this.enrollments.stream().filter(enrollment -> !enrollment.isAccepted()).collect(Collectors.toList()); 133 | } 134 | 135 | public void acceptWaitingList() { 136 | if (this.isAbleToAcceptWaitingEnrollment()) { 137 | var waitingList = getWaitingList(); 138 | int numberToAccept = (int) Math.min(this.limitOfEnrollments - this.getNumberOfAcceptedEnrollments(), waitingList.size()); 139 | waitingList.subList(0, numberToAccept).forEach(e -> e.setAccepted(true)); 140 | } 141 | } 142 | 143 | public void acceptNextWaitingEnrollment() { 144 | if (this.isAbleToAcceptWaitingEnrollment()) { 145 | Enrollment enrollmentToAccept = this.getTheFirstWaitingEnrollment(); 146 | if (enrollmentToAccept != null) { 147 | enrollmentToAccept.setAccepted(true); 148 | } 149 | } 150 | } 151 | 152 | private Enrollment getTheFirstWaitingEnrollment() { 153 | for (Enrollment e : this.enrollments) { 154 | if (!e.isAccepted()) { 155 | return e; 156 | } 157 | } 158 | 159 | return null; 160 | } 161 | 162 | public void accept(Enrollment enrollment) { 163 | if (this.eventType == EventType.CONFIRMATIVE 164 | && this.limitOfEnrollments > this.getNumberOfAcceptedEnrollments()) { 165 | enrollment.setAccepted(true); 166 | } 167 | } 168 | 169 | public void reject(Enrollment enrollment) { 170 | if (this.eventType == EventType.CONFIRMATIVE) { 171 | enrollment.setAccepted(false); 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/event/EventRepository.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.event; 2 | 3 | import com.studyolle.modules.study.Study; 4 | import org.springframework.data.jpa.repository.EntityGraph; 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | import org.springframework.transaction.annotation.Transactional; 7 | 8 | import java.util.List; 9 | 10 | @Transactional(readOnly = true) 11 | public interface EventRepository extends JpaRepository { 12 | 13 | @EntityGraph(value = "Event.withEnrollments", type = EntityGraph.EntityGraphType.LOAD) 14 | List findByStudyOrderByStartDateTime(Study study); 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/event/EventService.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.event; 2 | 3 | import com.studyolle.modules.account.Account; 4 | import com.studyolle.modules.event.event.EnrollmentAcceptedEvent; 5 | import com.studyolle.modules.event.event.EnrollmentRejectedEvent; 6 | import com.studyolle.modules.event.form.EventForm; 7 | import com.studyolle.modules.study.Study; 8 | import com.studyolle.modules.study.event.StudyUpdateEvent; 9 | import lombok.RequiredArgsConstructor; 10 | import org.modelmapper.ModelMapper; 11 | import org.springframework.context.ApplicationEventPublisher; 12 | import org.springframework.stereotype.Service; 13 | import org.springframework.transaction.annotation.Transactional; 14 | 15 | import java.time.LocalDateTime; 16 | 17 | @Service 18 | @Transactional 19 | @RequiredArgsConstructor 20 | public class EventService { 21 | 22 | private final EventRepository eventRepository; 23 | private final ModelMapper modelMapper; 24 | private final EnrollmentRepository enrollmentRepository; 25 | private final ApplicationEventPublisher eventPublisher; 26 | 27 | public Event createEvent(Event event, Study study, Account account) { 28 | event.setCreatedBy(account); 29 | event.setCreatedDateTime(LocalDateTime.now()); 30 | event.setStudy(study); 31 | eventPublisher.publishEvent(new StudyUpdateEvent(event.getStudy(), 32 | "'" + event.getTitle() + "' 모임을 만들었습니다.")); 33 | return eventRepository.save(event); 34 | } 35 | 36 | public void updateEvent(Event event, EventForm eventForm) { 37 | modelMapper.map(eventForm, event); 38 | event.acceptWaitingList(); 39 | eventPublisher.publishEvent(new StudyUpdateEvent(event.getStudy(), 40 | "'" + event.getTitle() + "' 모임 정보를 수정했으니 확인하세요.")); 41 | } 42 | 43 | public void deleteEvent(Event event) { 44 | eventRepository.delete(event); 45 | eventPublisher.publishEvent(new StudyUpdateEvent(event.getStudy(), 46 | "'" + event.getTitle() + "' 모임을 취소했습니다.")); 47 | } 48 | 49 | public void newEnrollment(Event event, Account account) { 50 | if (!enrollmentRepository.existsByEventAndAccount(event, account)) { 51 | Enrollment enrollment = new Enrollment(); 52 | enrollment.setEnrolledAt(LocalDateTime.now()); 53 | enrollment.setAccepted(event.isAbleToAcceptWaitingEnrollment()); 54 | enrollment.setAccount(account); 55 | event.addEnrollment(enrollment); 56 | enrollmentRepository.save(enrollment); 57 | } 58 | } 59 | 60 | public void cancelEnrollment(Event event, Account account) { 61 | Enrollment enrollment = enrollmentRepository.findByEventAndAccount(event, account); 62 | if (!enrollment.isAttended()) { 63 | event.removeEnrollment(enrollment); 64 | enrollmentRepository.delete(enrollment); 65 | event.acceptNextWaitingEnrollment(); 66 | } 67 | } 68 | 69 | public void acceptEnrollment(Event event, Enrollment enrollment) { 70 | event.accept(enrollment); 71 | eventPublisher.publishEvent(new EnrollmentAcceptedEvent(enrollment)); 72 | } 73 | 74 | public void rejectEnrollment(Event event, Enrollment enrollment) { 75 | event.reject(enrollment); 76 | eventPublisher.publishEvent(new EnrollmentRejectedEvent(enrollment)); 77 | } 78 | 79 | public void checkInEnrollment(Enrollment enrollment) { 80 | enrollment.setAttended(true); 81 | } 82 | 83 | public void cancelCheckInEnrollment(Enrollment enrollment) { 84 | enrollment.setAttended(false); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/event/EventType.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.event; 2 | 3 | public enum EventType { 4 | 5 | FCFS, CONFIRMATIVE; 6 | 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/event/event/EnrollmentAcceptedEvent.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.event.event; 2 | 3 | 4 | import com.studyolle.modules.event.Enrollment; 5 | 6 | public class EnrollmentAcceptedEvent extends EnrollmentEvent{ 7 | 8 | public EnrollmentAcceptedEvent(Enrollment enrollment) { 9 | super(enrollment, "모임 참가 신청을 확인했습니다. 모임에 참석하세요."); 10 | } 11 | 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/event/event/EnrollmentEvent.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.event.event; 2 | 3 | import com.studyolle.modules.event.Enrollment; 4 | import lombok.Getter; 5 | import lombok.RequiredArgsConstructor; 6 | 7 | @Getter 8 | @RequiredArgsConstructor 9 | public abstract class EnrollmentEvent { 10 | 11 | protected final Enrollment enrollment; 12 | 13 | protected final String message; 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/event/event/EnrollmentEventListener.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.event.event; 2 | 3 | import com.studyolle.infra.config.AppProperties; 4 | import com.studyolle.infra.mail.EmailMessage; 5 | import com.studyolle.infra.mail.EmailService; 6 | import com.studyolle.modules.account.Account; 7 | import com.studyolle.modules.event.Enrollment; 8 | import com.studyolle.modules.event.Event; 9 | import com.studyolle.modules.notification.Notification; 10 | import com.studyolle.modules.notification.NotificationRepository; 11 | import com.studyolle.modules.notification.NotificationType; 12 | import com.studyolle.modules.study.Study; 13 | import lombok.RequiredArgsConstructor; 14 | import lombok.extern.slf4j.Slf4j; 15 | import org.springframework.context.event.EventListener; 16 | import org.springframework.scheduling.annotation.Async; 17 | import org.springframework.stereotype.Component; 18 | import org.springframework.transaction.annotation.Transactional; 19 | import org.thymeleaf.TemplateEngine; 20 | import org.thymeleaf.context.Context; 21 | 22 | import java.time.LocalDateTime; 23 | 24 | @Slf4j 25 | @Async 26 | @Component 27 | @Transactional 28 | @RequiredArgsConstructor 29 | public class EnrollmentEventListener { 30 | 31 | private final NotificationRepository notificationRepository; 32 | private final AppProperties appProperties; 33 | private final TemplateEngine templateEngine; 34 | private final EmailService emailService; 35 | 36 | @EventListener 37 | public void handleEnrollmentEvent(EnrollmentEvent enrollmentEvent) { 38 | Enrollment enrollment = enrollmentEvent.getEnrollment(); 39 | Account account = enrollment.getAccount(); 40 | Event event = enrollment.getEvent(); 41 | Study study = event.getStudy(); 42 | 43 | if (account.isStudyEnrollmentResultByEmail()) { 44 | sendEmail(enrollmentEvent, account, event, study); 45 | } 46 | 47 | if (account.isStudyEnrollmentResultByWeb()) { 48 | createNotification(enrollmentEvent, account, event, study); 49 | } 50 | } 51 | 52 | private void sendEmail(EnrollmentEvent enrollmentEvent, Account account, Event event, Study study) { 53 | Context context = new Context(); 54 | context.setVariable("nickname", account.getNickname()); 55 | context.setVariable("link", "/study/" + study.getEncodedPath() + "/events/" + event.getId()); 56 | context.setVariable("linkName", study.getTitle()); 57 | context.setVariable("message", enrollmentEvent.getMessage()); 58 | context.setVariable("host", appProperties.getHost()); 59 | String message = templateEngine.process("mail/simple-link", context); 60 | 61 | EmailMessage emailMessage = EmailMessage.builder() 62 | .subject("스터디올래, " + event.getTitle() + " 모임 참가 신청 결과입니다.") 63 | .to(account.getEmail()) 64 | .message(message) 65 | .build(); 66 | 67 | emailService.sendEmail(emailMessage); 68 | } 69 | 70 | private void createNotification(EnrollmentEvent enrollmentEvent, Account account, Event event, Study study) { 71 | Notification notification = new Notification(); 72 | notification.setTitle(study.getTitle() + " / " + event.getTitle()); 73 | notification.setLink("/study/" + study.getEncodedPath() + "/events/" + event.getId()); 74 | notification.setChecked(false); 75 | notification.setCreatedDateTime(LocalDateTime.now()); 76 | notification.setMessage(enrollmentEvent.getMessage()); 77 | notification.setAccount(account); 78 | notification.setNotificationType(NotificationType.EVENT_ENROLLMENT); 79 | notificationRepository.save(notification); 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/event/event/EnrollmentRejectedEvent.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.event.event; 2 | 3 | 4 | import com.studyolle.modules.event.Enrollment; 5 | 6 | public class EnrollmentRejectedEvent extends EnrollmentEvent { 7 | 8 | public EnrollmentRejectedEvent(Enrollment enrollment) { 9 | super(enrollment, "모임 참가 신청을 거절했습니다."); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/event/form/EventForm.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.event.form; 2 | 3 | import com.studyolle.modules.event.EventType; 4 | import lombok.Data; 5 | import org.hibernate.validator.constraints.Length; 6 | import org.springframework.format.annotation.DateTimeFormat; 7 | 8 | import javax.validation.constraints.Min; 9 | import javax.validation.constraints.NotBlank; 10 | import java.time.LocalDateTime; 11 | 12 | @Data 13 | public class EventForm { 14 | 15 | @NotBlank 16 | @Length(max = 50) 17 | private String title; 18 | 19 | private String description; 20 | 21 | private EventType eventType = EventType.FCFS; 22 | 23 | @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) 24 | private LocalDateTime endEnrollmentDateTime; 25 | 26 | @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) 27 | private LocalDateTime startDateTime; 28 | 29 | @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) 30 | private LocalDateTime endDateTime; 31 | 32 | @Min(2) 33 | private Integer limitOfEnrollments = 2; 34 | 35 | 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/event/validator/EventValidator.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.event.validator; 2 | 3 | import com.studyolle.modules.event.Event; 4 | import com.studyolle.modules.event.form.EventForm; 5 | import org.springframework.stereotype.Component; 6 | import org.springframework.validation.Errors; 7 | import org.springframework.validation.Validator; 8 | 9 | import java.time.LocalDateTime; 10 | 11 | @Component 12 | public class EventValidator implements Validator { 13 | 14 | @Override 15 | public boolean supports(Class clazz) { 16 | return EventForm.class.isAssignableFrom(clazz); 17 | } 18 | 19 | @Override 20 | public void validate(Object target, Errors errors) { 21 | EventForm eventForm = (EventForm)target; 22 | 23 | if (isNotValidEndEnrollmentDateTime(eventForm)) { 24 | errors.rejectValue("endEnrollmentDateTime", "wrong.datetime", "모임 접수 종료 일시를 정확히 입력하세요."); 25 | } 26 | 27 | if (isNotValidEndDateTime(eventForm)) { 28 | errors.rejectValue("endDateTime", "wrong.datetime", "모임 종료 일시를 정확히 입력하세요."); 29 | } 30 | 31 | if (isNotValidStartDateTime(eventForm)) { 32 | errors.rejectValue("startDateTime", "wrong.datetime", "모임 시작 일시를 정확히 입력하세요."); 33 | } 34 | } 35 | 36 | private boolean isNotValidStartDateTime(EventForm eventForm) { 37 | return eventForm.getStartDateTime().isBefore(eventForm.getEndEnrollmentDateTime()); 38 | } 39 | 40 | private boolean isNotValidEndEnrollmentDateTime(EventForm eventForm) { 41 | return eventForm.getEndEnrollmentDateTime().isBefore(LocalDateTime.now()); 42 | } 43 | 44 | private boolean isNotValidEndDateTime(EventForm eventForm) { 45 | LocalDateTime endDateTime = eventForm.getEndDateTime(); 46 | return endDateTime.isBefore(eventForm.getStartDateTime()) || endDateTime.isBefore(eventForm.getEndEnrollmentDateTime()); 47 | } 48 | 49 | public void validateUpdateForm(EventForm eventForm, Event event, Errors errors) { 50 | if (eventForm.getLimitOfEnrollments() < event.getNumberOfAcceptedEnrollments()) { 51 | errors.rejectValue("limitOfEnrollments", "wrong.value", "확인된 참기 신청보다 모집 인원 수가 커야 합니다."); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/main/ExceptionAdvice.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.main; 2 | 3 | import com.studyolle.modules.account.Account; 4 | import com.studyolle.modules.account.CurrentAccount; 5 | import lombok.extern.slf4j.Slf4j; 6 | import org.springframework.web.bind.annotation.ControllerAdvice; 7 | import org.springframework.web.bind.annotation.ExceptionHandler; 8 | 9 | import javax.servlet.http.HttpServletRequest; 10 | 11 | @Slf4j 12 | @ControllerAdvice 13 | public class ExceptionAdvice { 14 | 15 | @ExceptionHandler 16 | public String handleRuntimeException(@CurrentAccount Account account, HttpServletRequest req, RuntimeException e) { 17 | if (account != null) { 18 | log.info("'{}' requested '{}'", account.getNickname(), req.getRequestURI()); 19 | } else { 20 | log.info("requested '{}'", req.getRequestURI()); 21 | } 22 | log.error("bad request", e); 23 | return "error"; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/main/MainController.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.main; 2 | 3 | import com.studyolle.modules.account.Account; 4 | import com.studyolle.modules.account.AccountRepository; 5 | import com.studyolle.modules.account.CurrentAccount; 6 | import com.studyolle.modules.event.EnrollmentRepository; 7 | import com.studyolle.modules.study.Study; 8 | import com.studyolle.modules.study.StudyRepository; 9 | import lombok.RequiredArgsConstructor; 10 | import org.springframework.data.domain.Page; 11 | import org.springframework.data.domain.Pageable; 12 | import org.springframework.data.domain.Sort; 13 | import org.springframework.data.web.PageableDefault; 14 | import org.springframework.stereotype.Controller; 15 | import org.springframework.ui.Model; 16 | import org.springframework.web.bind.annotation.GetMapping; 17 | 18 | @Controller 19 | @RequiredArgsConstructor 20 | public class MainController { 21 | 22 | private final StudyRepository studyRepository; 23 | private final EnrollmentRepository enrollmentRepository; 24 | private final AccountRepository accountRepository; 25 | 26 | @GetMapping("/") 27 | public String home(@CurrentAccount Account account, Model model) { 28 | if (account != null) { 29 | Account accountLoaded = accountRepository.findAccountWithTagsAndZonesById(account.getId()); 30 | model.addAttribute(accountLoaded); 31 | model.addAttribute("enrollmentList", enrollmentRepository.findByAccountAndAcceptedOrderByEnrolledAtDesc(accountLoaded, true)); 32 | model.addAttribute("studyList", studyRepository.findByAccount( 33 | accountLoaded.getTags(), 34 | accountLoaded.getZones())); 35 | model.addAttribute("studyManagerOf", 36 | studyRepository.findFirst5ByManagersContainingAndClosedOrderByPublishedDateTimeDesc(account, false)); 37 | model.addAttribute("studyMemberOf", 38 | studyRepository.findFirst5ByMembersContainingAndClosedOrderByPublishedDateTimeDesc(account, false)); 39 | return "index-after-login"; 40 | } 41 | 42 | model.addAttribute("studyList", studyRepository.findFirst9ByPublishedAndClosedOrderByPublishedDateTimeDesc(true, false)); 43 | return "index"; 44 | } 45 | 46 | @GetMapping("/login") 47 | public String login() { 48 | return "login"; 49 | } 50 | 51 | @GetMapping("/search/study") 52 | public String searchStudy(String keyword, Model model, 53 | @PageableDefault(size = 9, sort = "publishedDateTime", direction = Sort.Direction.DESC) 54 | Pageable pageable) { 55 | Page studyPage = studyRepository.findByKeyword(keyword, pageable); 56 | model.addAttribute("studyPage", studyPage); 57 | model.addAttribute("keyword", keyword); 58 | model.addAttribute("sortProperty", 59 | pageable.getSort().toString().contains("publishedDateTime") ? "publishedDateTime" : "memberCount"); 60 | return "search"; 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/notification/Notification.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.notification; 2 | 3 | import com.studyolle.modules.account.Account; 4 | import lombok.EqualsAndHashCode; 5 | import lombok.Getter; 6 | import lombok.Setter; 7 | 8 | import javax.persistence.*; 9 | import java.time.LocalDateTime; 10 | 11 | @Entity 12 | @Getter @Setter @EqualsAndHashCode(of = "id") 13 | public class Notification { 14 | 15 | @Id @GeneratedValue 16 | private Long id; 17 | 18 | private String title; 19 | 20 | private String link; 21 | 22 | private String message; 23 | 24 | private boolean checked; 25 | 26 | @ManyToOne 27 | private Account account; 28 | 29 | private LocalDateTime createdDateTime; 30 | 31 | @Enumerated(EnumType.STRING) 32 | private NotificationType notificationType; 33 | 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/notification/NotificationController.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.notification; 2 | 3 | import com.studyolle.modules.account.Account; 4 | import com.studyolle.modules.account.CurrentAccount; 5 | import lombok.RequiredArgsConstructor; 6 | import org.springframework.stereotype.Controller; 7 | import org.springframework.ui.Model; 8 | import org.springframework.web.bind.annotation.DeleteMapping; 9 | import org.springframework.web.bind.annotation.GetMapping; 10 | 11 | import java.util.ArrayList; 12 | import java.util.List; 13 | 14 | @Controller 15 | @RequiredArgsConstructor 16 | public class NotificationController { 17 | 18 | private final NotificationRepository repository; 19 | 20 | private final NotificationService service; 21 | 22 | @GetMapping("/notifications") 23 | public String getNotifications(@CurrentAccount Account account, Model model) { 24 | List notifications = repository.findByAccountAndCheckedOrderByCreatedDateTimeDesc(account, false); 25 | long numberOfChecked = repository.countByAccountAndChecked(account, true); 26 | putCategorizedNotifications(model, notifications, numberOfChecked, notifications.size()); 27 | model.addAttribute("isNew", true); 28 | service.markAsRead(notifications); 29 | return "notification/list"; 30 | } 31 | 32 | @GetMapping("/notifications/old") 33 | public String getOldNotifications(@CurrentAccount Account account, Model model) { 34 | List notifications = repository.findByAccountAndCheckedOrderByCreatedDateTimeDesc(account, true); 35 | long numberOfNotChecked = repository.countByAccountAndChecked(account, false); 36 | putCategorizedNotifications(model, notifications, notifications.size(), numberOfNotChecked); 37 | model.addAttribute("isNew", false); 38 | return "notification/list"; 39 | } 40 | 41 | @DeleteMapping("/notifications") 42 | public String deleteNotifications(@CurrentAccount Account account) { 43 | repository.deleteByAccountAndChecked(account, true); 44 | return "redirect:/notifications"; 45 | } 46 | 47 | private void putCategorizedNotifications(Model model, List notifications, 48 | long numberOfChecked, long numberOfNotChecked) { 49 | List newStudyNotifications = new ArrayList<>(); 50 | List eventEnrollmentNotifications = new ArrayList<>(); 51 | List watchingStudyNotifications = new ArrayList<>(); 52 | for (var notification : notifications) { 53 | switch (notification.getNotificationType()) { 54 | case STUDY_CREATED: newStudyNotifications.add(notification); break; 55 | case EVENT_ENROLLMENT: eventEnrollmentNotifications.add(notification); break; 56 | case STUDY_UPDATED: watchingStudyNotifications.add(notification); break; 57 | } 58 | } 59 | 60 | model.addAttribute("numberOfNotChecked", numberOfNotChecked); 61 | model.addAttribute("numberOfChecked", numberOfChecked); 62 | model.addAttribute("notifications", notifications); 63 | model.addAttribute("newStudyNotifications", newStudyNotifications); 64 | model.addAttribute("eventEnrollmentNotifications", eventEnrollmentNotifications); 65 | model.addAttribute("watchingStudyNotifications", watchingStudyNotifications); 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/notification/NotificationInterceptor.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.notification; 2 | 3 | import com.studyolle.modules.account.Account; 4 | import com.studyolle.modules.account.UserAccount; 5 | import lombok.RequiredArgsConstructor; 6 | import org.springframework.security.core.Authentication; 7 | import org.springframework.security.core.context.SecurityContextHolder; 8 | import org.springframework.stereotype.Component; 9 | import org.springframework.web.servlet.HandlerInterceptor; 10 | import org.springframework.web.servlet.ModelAndView; 11 | import org.springframework.web.servlet.view.RedirectView; 12 | 13 | import javax.servlet.http.HttpServletRequest; 14 | import javax.servlet.http.HttpServletResponse; 15 | 16 | @Component 17 | @RequiredArgsConstructor 18 | public class NotificationInterceptor implements HandlerInterceptor { 19 | 20 | private final NotificationRepository notificationRepository; 21 | 22 | @Override 23 | public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { 24 | Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); 25 | if (modelAndView != null && !isRedirectView(modelAndView) && authentication != null && authentication.getPrincipal() instanceof UserAccount) { 26 | Account account = ((UserAccount)authentication.getPrincipal()).getAccount(); 27 | long count = notificationRepository.countByAccountAndChecked(account, false); 28 | modelAndView.addObject("hasNotification", count > 0); 29 | } 30 | } 31 | 32 | private boolean isRedirectView(ModelAndView modelAndView) { 33 | return modelAndView.getViewName().startsWith("redirect:") || modelAndView.getView() instanceof RedirectView; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/notification/NotificationRepository.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.notification; 2 | 3 | import com.studyolle.modules.account.Account; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | import org.springframework.transaction.annotation.Transactional; 6 | 7 | import java.util.List; 8 | 9 | @Transactional(readOnly = true) 10 | public interface NotificationRepository extends JpaRepository { 11 | long countByAccountAndChecked(Account account, boolean checked); 12 | 13 | @Transactional 14 | List findByAccountAndCheckedOrderByCreatedDateTimeDesc(Account account, boolean checked); 15 | 16 | @Transactional 17 | void deleteByAccountAndChecked(Account account, boolean checked); 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/notification/NotificationService.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.notification; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import org.springframework.stereotype.Service; 5 | import org.springframework.transaction.annotation.Transactional; 6 | 7 | import java.util.List; 8 | 9 | @Service 10 | @Transactional 11 | @RequiredArgsConstructor 12 | public class NotificationService { 13 | 14 | private final NotificationRepository notificationRepository; 15 | 16 | public void markAsRead(List notifications) { 17 | notifications.forEach(n -> n.setChecked(true)); 18 | notificationRepository.saveAll(notifications); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/notification/NotificationType.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.notification; 2 | 3 | public enum NotificationType { 4 | 5 | STUDY_CREATED, STUDY_UPDATED, EVENT_ENROLLMENT; 6 | 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/study/Study.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.study; 2 | 3 | import com.studyolle.modules.account.Account; 4 | import com.studyolle.modules.account.UserAccount; 5 | import com.studyolle.modules.tag.Tag; 6 | import com.studyolle.modules.zone.Zone; 7 | import lombok.*; 8 | 9 | import javax.persistence.*; 10 | import java.net.URLEncoder; 11 | import java.nio.charset.StandardCharsets; 12 | import java.time.LocalDateTime; 13 | import java.util.HashSet; 14 | import java.util.Set; 15 | 16 | @Entity 17 | @Getter @Setter @EqualsAndHashCode(of = "id") 18 | @Builder @AllArgsConstructor @NoArgsConstructor 19 | public class Study { 20 | 21 | @Id @GeneratedValue 22 | private Long id; 23 | 24 | @ManyToMany 25 | private Set managers = new HashSet<>(); 26 | 27 | @ManyToMany 28 | private Set members = new HashSet<>(); 29 | 30 | @Column(unique = true) 31 | private String path; 32 | 33 | private String title; 34 | 35 | private String shortDescription; 36 | 37 | @Lob @Basic(fetch = FetchType.EAGER) 38 | private String fullDescription; 39 | 40 | @Lob @Basic(fetch = FetchType.EAGER) 41 | private String image; 42 | 43 | @ManyToMany 44 | private Set tags = new HashSet<>(); 45 | 46 | @ManyToMany 47 | private Set zones = new HashSet<>(); 48 | 49 | private LocalDateTime publishedDateTime; 50 | 51 | private LocalDateTime closedDateTime; 52 | 53 | private LocalDateTime recruitingUpdatedDateTime; 54 | 55 | private boolean recruiting; 56 | 57 | private boolean published; 58 | 59 | private boolean closed; 60 | 61 | private boolean useBanner; 62 | 63 | private int memberCount; 64 | 65 | public void addManager(Account account) { 66 | this.managers.add(account); 67 | } 68 | 69 | public boolean isJoinable(UserAccount userAccount) { 70 | Account account = userAccount.getAccount(); 71 | return this.isPublished() && this.isRecruiting() 72 | && !this.members.contains(account) && !this.managers.contains(account); 73 | 74 | } 75 | 76 | public boolean isMember(UserAccount userAccount) { 77 | return this.members.contains(userAccount.getAccount()); 78 | } 79 | 80 | public boolean isManager(UserAccount userAccount) { 81 | return this.managers.contains(userAccount.getAccount()); 82 | } 83 | 84 | public void addMemeber(Account account) { 85 | this.members.add(account); 86 | } 87 | 88 | public String getImage() { 89 | return image != null ? image : "/images/default_banner.png"; 90 | } 91 | 92 | public void publish() { 93 | if (!this.closed && !this.published) { 94 | this.published = true; 95 | this.publishedDateTime = LocalDateTime.now(); 96 | } else { 97 | throw new RuntimeException("스터디를 공개할 수 없는 상태입니다. 스터디를 이미 공개했거나 종료했습니다."); 98 | } 99 | } 100 | 101 | public void close() { 102 | if (this.published && !this.closed) { 103 | this.closed = true; 104 | this.closedDateTime = LocalDateTime.now(); 105 | } else { 106 | throw new RuntimeException("스터디를 종료할 수 없습니다. 스터디를 공개하지 않았거나 이미 종료한 스터디입니다."); 107 | } 108 | } 109 | 110 | public void startRecruit() { 111 | if (canUpdateRecruiting()) { 112 | this.recruiting = true; 113 | this.recruitingUpdatedDateTime = LocalDateTime.now(); 114 | } else { 115 | throw new RuntimeException("인원 모집을 시작할 수 없습니다. 스터디를 공개하거나 한 시간 뒤 다시 시도하세요."); 116 | } 117 | } 118 | 119 | public void stopRecruit() { 120 | if (canUpdateRecruiting()) { 121 | this.recruiting = false; 122 | this.recruitingUpdatedDateTime = LocalDateTime.now(); 123 | } else { 124 | throw new RuntimeException("인원 모집을 멈출 수 없습니다. 스터디를 공개하거나 한 시간 뒤 다시 시도하세요."); 125 | } 126 | } 127 | 128 | public boolean canUpdateRecruiting() { 129 | return this.published && this.recruitingUpdatedDateTime == null || this.recruitingUpdatedDateTime.isBefore(LocalDateTime.now().minusHours(1)); 130 | } 131 | 132 | public boolean isRemovable() { 133 | return !this.published; // TODO 모임을 했던 스터디는 삭제할 수 없다. 134 | } 135 | 136 | public void addMember(Account account) { 137 | this.getMembers().add(account); 138 | this.memberCount++; 139 | } 140 | 141 | public void removeMember(Account account) { 142 | this.getMembers().remove(account); 143 | this.memberCount--; 144 | } 145 | 146 | public String getEncodedPath() { 147 | return URLEncoder.encode(this.path, StandardCharsets.UTF_8); 148 | } 149 | 150 | public boolean isManagedBy(Account account) { 151 | return this.getManagers().contains(account); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/study/StudyController.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.study; 2 | 3 | import com.studyolle.modules.account.Account; 4 | import com.studyolle.modules.account.CurrentAccount; 5 | import com.studyolle.modules.study.form.StudyForm; 6 | import com.studyolle.modules.study.validator.StudyFormValidator; 7 | import lombok.RequiredArgsConstructor; 8 | import org.modelmapper.ModelMapper; 9 | import org.springframework.stereotype.Controller; 10 | import org.springframework.ui.Model; 11 | import org.springframework.validation.Errors; 12 | import org.springframework.web.bind.WebDataBinder; 13 | import org.springframework.web.bind.annotation.GetMapping; 14 | import org.springframework.web.bind.annotation.InitBinder; 15 | import org.springframework.web.bind.annotation.PathVariable; 16 | import org.springframework.web.bind.annotation.PostMapping; 17 | 18 | import javax.validation.Valid; 19 | import java.net.URLEncoder; 20 | import java.nio.charset.StandardCharsets; 21 | 22 | @Controller 23 | @RequiredArgsConstructor 24 | public class StudyController { 25 | 26 | private final StudyRepository studyRepository; 27 | private final StudyService studyService; 28 | private final ModelMapper modelMapper; 29 | private final StudyFormValidator studyFormValidator; 30 | 31 | @InitBinder("studyForm") 32 | public void studyFormInitBinder(WebDataBinder webDataBinder) { 33 | webDataBinder.addValidators(studyFormValidator); 34 | } 35 | 36 | @GetMapping("/new-study") 37 | public String newStudyForm(@CurrentAccount Account account, Model model) { 38 | model.addAttribute(account); 39 | model.addAttribute(new StudyForm()); 40 | return "study/form"; 41 | } 42 | 43 | @PostMapping("/new-study") 44 | public String newStudySubmit(@CurrentAccount Account account, @Valid StudyForm studyForm, Errors errors, Model model) { 45 | if (errors.hasErrors()) { 46 | model.addAttribute(account); 47 | return "study/form"; 48 | } 49 | 50 | Study newStudy = studyService.createNewStudy(modelMapper.map(studyForm, Study.class), account); 51 | return "redirect:/study/" + URLEncoder.encode(newStudy.getPath(), StandardCharsets.UTF_8); 52 | } 53 | 54 | @GetMapping("/study/{path}") 55 | public String viewStudy(@CurrentAccount Account account, @PathVariable String path, Model model) { 56 | Study study = studyService.getStudy(path); 57 | model.addAttribute(account); 58 | model.addAttribute(study); 59 | return "study/view"; 60 | } 61 | 62 | @GetMapping("/study/{path}/members") 63 | public String viewStudyMembers(@CurrentAccount Account account, @PathVariable String path, Model model) { 64 | Study study = studyService.getStudy(path); 65 | model.addAttribute(account); 66 | model.addAttribute(study); 67 | return "study/members"; 68 | } 69 | 70 | @GetMapping("/study/{path}/join") 71 | public String joinStudy(@CurrentAccount Account account, @PathVariable String path) { 72 | Study study = studyRepository.findStudyWithMembersByPath(path); 73 | studyService.addMember(study, account); 74 | return "redirect:/study/" + study.getEncodedPath() + "/members"; 75 | } 76 | 77 | @GetMapping("/study/{path}/leave") 78 | public String leaveStudy(@CurrentAccount Account account, @PathVariable String path) { 79 | Study study = studyRepository.findStudyWithMembersByPath(path); 80 | studyService.removeMember(study, account); 81 | return "redirect:/study/" + study.getEncodedPath() + "/members"; 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/study/StudyRepository.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.study; 2 | 3 | import com.studyolle.modules.account.Account; 4 | import org.springframework.data.jpa.repository.EntityGraph; 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | import org.springframework.transaction.annotation.Transactional; 7 | 8 | import java.util.List; 9 | 10 | @Transactional(readOnly = true) 11 | public interface StudyRepository extends JpaRepository, StudyRepositoryExtension { 12 | 13 | boolean existsByPath(String path); 14 | 15 | @EntityGraph(attributePaths = {"tags", "zones", "managers", "members"}, type = EntityGraph.EntityGraphType.LOAD) 16 | Study findByPath(String path); 17 | 18 | @EntityGraph(attributePaths = {"tags", "managers"}) 19 | Study findStudyWithTagsByPath(String path); 20 | 21 | @EntityGraph(attributePaths = {"zones", "managers"}) 22 | Study findStudyWithZonesByPath(String path); 23 | 24 | @EntityGraph(attributePaths = "managers") 25 | Study findStudyWithManagersByPath(String path); 26 | 27 | @EntityGraph(attributePaths = "members") 28 | Study findStudyWithMembersByPath(String path); 29 | 30 | Study findStudyOnlyByPath(String path); 31 | 32 | @EntityGraph(attributePaths = {"zones", "tags"}) 33 | Study findStudyWithTagsAndZonesById(Long id); 34 | 35 | @EntityGraph(attributePaths = {"members", "managers"}) 36 | Study findStudyWithManagersAndMemebersById(Long id); 37 | 38 | @EntityGraph(attributePaths = {"zones", "tags"}) 39 | List findFirst9ByPublishedAndClosedOrderByPublishedDateTimeDesc(boolean published, boolean closed); 40 | 41 | List findFirst5ByManagersContainingAndClosedOrderByPublishedDateTimeDesc(Account account, boolean closed); 42 | 43 | List findFirst5ByMembersContainingAndClosedOrderByPublishedDateTimeDesc(Account account, boolean closed); 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/study/StudyRepositoryExtension.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.study; 2 | 3 | import com.studyolle.modules.tag.Tag; 4 | import com.studyolle.modules.zone.Zone; 5 | import org.springframework.data.domain.Page; 6 | import org.springframework.data.domain.Pageable; 7 | import org.springframework.transaction.annotation.Transactional; 8 | 9 | import java.util.List; 10 | import java.util.Set; 11 | 12 | @Transactional(readOnly = true) 13 | public interface StudyRepositoryExtension { 14 | 15 | Page findByKeyword(String keyword, Pageable pageable); 16 | 17 | List findByAccount(Set tags, Set zones); 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/study/StudyRepositoryExtensionImpl.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.study; 2 | 3 | import com.querydsl.core.QueryResults; 4 | import com.querydsl.jpa.JPQLQuery; 5 | import com.studyolle.modules.tag.QTag; 6 | import com.studyolle.modules.tag.Tag; 7 | import com.studyolle.modules.zone.QZone; 8 | import com.studyolle.modules.zone.Zone; 9 | import org.springframework.data.domain.Page; 10 | import org.springframework.data.domain.PageImpl; 11 | import org.springframework.data.domain.Pageable; 12 | import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport; 13 | 14 | import java.util.List; 15 | import java.util.Set; 16 | 17 | public class StudyRepositoryExtensionImpl extends QuerydslRepositorySupport implements StudyRepositoryExtension { 18 | 19 | public StudyRepositoryExtensionImpl() { 20 | super(Study.class); 21 | } 22 | 23 | @Override 24 | public Page findByKeyword(String keyword, Pageable pageable) { 25 | QStudy study = QStudy.study; 26 | JPQLQuery query = from(study).where(study.published.isTrue() 27 | .and(study.title.containsIgnoreCase(keyword)) 28 | .or(study.tags.any().title.containsIgnoreCase(keyword)) 29 | .or(study.zones.any().localNameOfCity.containsIgnoreCase(keyword))) 30 | .leftJoin(study.tags, QTag.tag).fetchJoin() 31 | .leftJoin(study.zones, QZone.zone).fetchJoin() 32 | .distinct(); 33 | JPQLQuery pageableQuery = getQuerydsl().applyPagination(pageable, query); 34 | QueryResults fetchResults = pageableQuery.fetchResults(); 35 | return new PageImpl<>(fetchResults.getResults(), pageable, fetchResults.getTotal()); 36 | } 37 | 38 | @Override 39 | public List findByAccount(Set tags, Set zones) { 40 | QStudy study = QStudy.study; 41 | JPQLQuery query = from(study).where(study.published.isTrue() 42 | .and(study.closed.isFalse()) 43 | .and(study.tags.any().in(tags)) 44 | .and(study.zones.any().in(zones))) 45 | .leftJoin(study.tags, QTag.tag).fetchJoin() 46 | .leftJoin(study.zones, QZone.zone).fetchJoin() 47 | .orderBy(study.publishedDateTime.desc()) 48 | .distinct() 49 | .limit(9); 50 | return query.fetch(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/study/StudyService.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.study; 2 | 3 | import com.studyolle.modules.account.Account; 4 | import com.studyolle.modules.study.event.StudyCreatedEvent; 5 | import com.studyolle.modules.study.event.StudyUpdateEvent; 6 | import com.studyolle.modules.study.form.StudyDescriptionForm; 7 | import com.studyolle.modules.tag.Tag; 8 | import com.studyolle.modules.zone.Zone; 9 | import lombok.RequiredArgsConstructor; 10 | import org.modelmapper.ModelMapper; 11 | import org.springframework.context.ApplicationEventPublisher; 12 | import org.springframework.security.access.AccessDeniedException; 13 | import org.springframework.stereotype.Service; 14 | import org.springframework.transaction.annotation.Transactional; 15 | 16 | import static com.studyolle.modules.study.form.StudyForm.VALID_PATH_PATTERN; 17 | 18 | @Service 19 | @Transactional 20 | @RequiredArgsConstructor 21 | public class StudyService { 22 | 23 | private final StudyRepository repository; 24 | private final ModelMapper modelMapper; 25 | private final ApplicationEventPublisher eventPublisher; 26 | 27 | public Study createNewStudy(Study study, Account account) { 28 | Study newStudy = repository.save(study); 29 | newStudy.addManager(account); 30 | return newStudy; 31 | } 32 | 33 | public Study getStudyToUpdate(Account account, String path) { 34 | Study study = this.getStudy(path); 35 | checkIfManager(account, study); 36 | return study; 37 | } 38 | 39 | public Study getStudy(String path) { 40 | Study study = this.repository.findByPath(path); 41 | checkIfExistingStudy(path, study); 42 | return study; 43 | } 44 | 45 | public void updateStudyDescription(Study study, StudyDescriptionForm studyDescriptionForm) { 46 | modelMapper.map(studyDescriptionForm, study); 47 | eventPublisher.publishEvent(new StudyUpdateEvent(study, "스터디 소개를 수정했습니다.")); 48 | } 49 | 50 | public void updateStudyImage(Study study, String image) { 51 | study.setImage(image); 52 | } 53 | 54 | public void enableStudyBanner(Study study) { 55 | study.setUseBanner(true); 56 | } 57 | 58 | public void disableStudyBanner(Study study) { 59 | study.setUseBanner(false); 60 | } 61 | 62 | public void addTag(Study study, Tag tag) { 63 | study.getTags().add(tag); 64 | } 65 | 66 | public void removeTag(Study study, Tag tag) { 67 | study.getTags().remove(tag); 68 | } 69 | 70 | public void addZone(Study study, Zone zone) { 71 | study.getZones().add(zone); 72 | } 73 | 74 | public void removeZone(Study study, Zone zone) { 75 | study.getZones().remove(zone); 76 | } 77 | 78 | public Study getStudyToUpdateTag(Account account, String path) { 79 | Study study = repository.findStudyWithTagsByPath(path); 80 | checkIfExistingStudy(path, study); 81 | checkIfManager(account, study); 82 | return study; 83 | } 84 | 85 | public Study getStudyToUpdateZone(Account account, String path) { 86 | Study study = repository.findStudyWithZonesByPath(path); 87 | checkIfExistingStudy(path, study); 88 | checkIfManager(account, study); 89 | return study; 90 | } 91 | 92 | public Study getStudyToUpdateStatus(Account account, String path) { 93 | Study study = repository.findStudyWithManagersByPath(path); 94 | checkIfExistingStudy(path, study); 95 | checkIfManager(account, study); 96 | return study; 97 | } 98 | 99 | private void checkIfManager(Account account, Study study) { 100 | if (!study.isManagedBy(account)) { 101 | throw new AccessDeniedException("해당 기능을 사용할 수 없습니다."); 102 | } 103 | } 104 | 105 | private void checkIfExistingStudy(String path, Study study) { 106 | if (study == null) { 107 | throw new IllegalArgumentException(path + "에 해당하는 스터디가 없습니다."); 108 | } 109 | } 110 | 111 | public void publish(Study study) { 112 | study.publish(); 113 | this.eventPublisher.publishEvent(new StudyCreatedEvent(study)); 114 | } 115 | 116 | public void close(Study study) { 117 | study.close(); 118 | eventPublisher.publishEvent(new StudyUpdateEvent(study, "스터디를 종료했습니다.")); 119 | } 120 | 121 | public void startRecruit(Study study) { 122 | study.startRecruit(); 123 | eventPublisher.publishEvent(new StudyUpdateEvent(study, "팀원 모집을 시작합니다.")); 124 | } 125 | 126 | public void stopRecruit(Study study) { 127 | study.stopRecruit(); 128 | eventPublisher.publishEvent(new StudyUpdateEvent(study, "팀원 모집을 중단했습니다.")); 129 | } 130 | 131 | public boolean isValidPath(String newPath) { 132 | if (!newPath.matches(VALID_PATH_PATTERN)) { 133 | return false; 134 | } 135 | 136 | return !repository.existsByPath(newPath); 137 | } 138 | 139 | public void updateStudyPath(Study study, String newPath) { 140 | study.setPath(newPath); 141 | } 142 | 143 | public boolean isValidTitle(String newTitle) { 144 | return newTitle.length() <= 50; 145 | } 146 | 147 | public void updateStudyTitle(Study study, String newTitle) { 148 | study.setTitle(newTitle); 149 | } 150 | 151 | public void remove(Study study) { 152 | if (study.isRemovable()) { 153 | repository.delete(study); 154 | } else { 155 | throw new IllegalArgumentException("스터디를 삭제할 수 없습니다."); 156 | } 157 | } 158 | 159 | public void addMember(Study study, Account account) { 160 | study.addMember(account); 161 | } 162 | 163 | public void removeMember(Study study, Account account) { 164 | study.removeMember(account); 165 | } 166 | 167 | public Study getStudyToEnroll(String path) { 168 | Study study = repository.findStudyOnlyByPath(path); 169 | checkIfExistingStudy(path, study); 170 | return study; 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/study/event/StudyCreatedEvent.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.study.event; 2 | 3 | import com.studyolle.modules.study.Study; 4 | import lombok.Getter; 5 | import lombok.RequiredArgsConstructor; 6 | 7 | @Getter 8 | @RequiredArgsConstructor 9 | public class StudyCreatedEvent { 10 | 11 | private final Study study; 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/study/event/StudyEventListener.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.study.event; 2 | 3 | import com.studyolle.infra.config.AppProperties; 4 | import com.studyolle.infra.mail.EmailMessage; 5 | import com.studyolle.infra.mail.EmailService; 6 | import com.studyolle.modules.account.Account; 7 | import com.studyolle.modules.account.AccountPredicates; 8 | import com.studyolle.modules.account.AccountRepository; 9 | import com.studyolle.modules.notification.Notification; 10 | import com.studyolle.modules.notification.NotificationRepository; 11 | import com.studyolle.modules.notification.NotificationType; 12 | import com.studyolle.modules.study.Study; 13 | import com.studyolle.modules.study.StudyRepository; 14 | import lombok.RequiredArgsConstructor; 15 | import lombok.extern.slf4j.Slf4j; 16 | import org.springframework.context.event.EventListener; 17 | import org.springframework.scheduling.annotation.Async; 18 | import org.springframework.stereotype.Component; 19 | import org.springframework.transaction.annotation.Transactional; 20 | import org.thymeleaf.TemplateEngine; 21 | import org.thymeleaf.context.Context; 22 | 23 | import java.time.LocalDateTime; 24 | import java.util.HashSet; 25 | import java.util.Set; 26 | 27 | @Slf4j 28 | @Async 29 | @Component 30 | @Transactional 31 | @RequiredArgsConstructor 32 | public class StudyEventListener { 33 | 34 | private final StudyRepository studyRepository; 35 | private final AccountRepository accountRepository; 36 | private final EmailService emailService; 37 | private final TemplateEngine templateEngine; 38 | private final AppProperties appProperties; 39 | private final NotificationRepository notificationRepository; 40 | 41 | @EventListener 42 | public void handleStudyCreatedEvent(StudyCreatedEvent studyCreatedEvent) { 43 | Study study = studyRepository.findStudyWithTagsAndZonesById(studyCreatedEvent.getStudy().getId()); 44 | Iterable accounts = accountRepository.findAll(AccountPredicates.findByTagsAndZones(study.getTags(), study.getZones())); 45 | accounts.forEach(account -> { 46 | if (account.isStudyCreatedByEmail()) { 47 | sendStudyCreatedEmail(study, account, "새로운 스터디가 생겼습니다", 48 | "스터디올래, '" + study.getTitle() + "' 스터디가 생겼습니다."); 49 | } 50 | 51 | if (account.isStudyCreatedByWeb()) { 52 | createNotification(study, account, study.getShortDescription(), NotificationType.STUDY_CREATED); 53 | } 54 | }); 55 | } 56 | 57 | @EventListener 58 | public void handleStudyUpdateEvent(StudyUpdateEvent studyUpdateEvent) { 59 | Study study = studyRepository.findStudyWithManagersAndMemebersById(studyUpdateEvent.getStudy().getId()); 60 | Set accounts = new HashSet<>(); 61 | accounts.addAll(study.getManagers()); 62 | accounts.addAll(study.getMembers()); 63 | 64 | accounts.forEach(account -> { 65 | if (account.isStudyUpdatedByEmail()) { 66 | sendStudyCreatedEmail(study, account, studyUpdateEvent.getMessage(), 67 | "스터디올래, '" + study.getTitle() + "' 스터디에 새소식이 있습니다."); 68 | } 69 | 70 | if (account.isStudyUpdatedByWeb()) { 71 | createNotification(study, account, studyUpdateEvent.getMessage(), NotificationType.STUDY_UPDATED); 72 | } 73 | }); 74 | } 75 | 76 | private void createNotification(Study study, Account account, String message, NotificationType notificationType) { 77 | Notification notification = new Notification(); 78 | notification.setTitle(study.getTitle()); 79 | notification.setLink("/study/" + study.getEncodedPath()); 80 | notification.setChecked(false); 81 | notification.setCreatedDateTime(LocalDateTime.now()); 82 | notification.setMessage(message); 83 | notification.setAccount(account); 84 | notification.setNotificationType(notificationType); 85 | notificationRepository.save(notification); 86 | } 87 | 88 | private void sendStudyCreatedEmail(Study study, Account account, String contextMessage, String emailSubject) { 89 | Context context = new Context(); 90 | context.setVariable("nickname", account.getNickname()); 91 | context.setVariable("link", "/study/" + study.getEncodedPath()); 92 | context.setVariable("linkName", study.getTitle()); 93 | context.setVariable("message", contextMessage); 94 | context.setVariable("host", appProperties.getHost()); 95 | String message = templateEngine.process("mail/simple-link", context); 96 | 97 | EmailMessage emailMessage = EmailMessage.builder() 98 | .subject(emailSubject) 99 | .to(account.getEmail()) 100 | .message(message) 101 | .build(); 102 | 103 | emailService.sendEmail(emailMessage); 104 | } 105 | 106 | } 107 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/study/event/StudyUpdateEvent.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.study.event; 2 | 3 | import com.studyolle.modules.study.Study; 4 | import lombok.Getter; 5 | import lombok.RequiredArgsConstructor; 6 | 7 | @Getter 8 | @RequiredArgsConstructor 9 | public class StudyUpdateEvent { 10 | 11 | private final Study study; 12 | 13 | private final String message; 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/study/form/StudyDescriptionForm.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.study.form; 2 | 3 | import lombok.Data; 4 | import lombok.NoArgsConstructor; 5 | import org.hibernate.validator.constraints.Length; 6 | 7 | import javax.validation.constraints.NotBlank; 8 | 9 | @Data 10 | @NoArgsConstructor 11 | public class StudyDescriptionForm { 12 | 13 | @NotBlank 14 | @Length(max = 100) 15 | private String shortDescription; 16 | 17 | @NotBlank 18 | private String fullDescription; 19 | 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/study/form/StudyForm.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.study.form; 2 | 3 | import lombok.Data; 4 | import org.hibernate.validator.constraints.Length; 5 | 6 | import javax.validation.constraints.NotBlank; 7 | import javax.validation.constraints.Pattern; 8 | 9 | @Data 10 | public class StudyForm { 11 | 12 | public static final String VALID_PATH_PATTERN = "^[ㄱ-ㅎ가-힣a-z0-9_-]{2,20}$"; 13 | 14 | @NotBlank 15 | @Length(min = 2, max = 20) 16 | @Pattern(regexp = VALID_PATH_PATTERN) 17 | private String path; 18 | 19 | @NotBlank 20 | @Length(max = 50) 21 | private String title; 22 | 23 | @NotBlank 24 | @Length(max = 100) 25 | private String shortDescription; 26 | 27 | @NotBlank 28 | private String fullDescription; 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/study/validator/StudyFormValidator.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.study.validator; 2 | 3 | import com.studyolle.modules.study.StudyRepository; 4 | import com.studyolle.modules.study.form.StudyForm; 5 | import lombok.RequiredArgsConstructor; 6 | import org.springframework.stereotype.Component; 7 | import org.springframework.validation.Errors; 8 | import org.springframework.validation.Validator; 9 | 10 | @Component 11 | @RequiredArgsConstructor 12 | public class StudyFormValidator implements Validator { 13 | 14 | private final StudyRepository studyRepository; 15 | 16 | @Override 17 | public boolean supports(Class clazz) { 18 | return StudyForm.class.isAssignableFrom(clazz); 19 | } 20 | 21 | @Override 22 | public void validate(Object target, Errors errors) { 23 | StudyForm studyForm = (StudyForm)target; 24 | if (studyRepository.existsByPath(studyForm.getPath())) { 25 | errors.rejectValue("path", "wrong.path", "해당 스터디 경로값을 사용할 수 없습니다."); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/tag/Tag.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.tag; 2 | 3 | import lombok.*; 4 | 5 | import javax.persistence.Column; 6 | import javax.persistence.Entity; 7 | import javax.persistence.GeneratedValue; 8 | import javax.persistence.Id; 9 | 10 | @Entity 11 | @Getter @Setter @EqualsAndHashCode(of = "id") 12 | @Builder @AllArgsConstructor @NoArgsConstructor 13 | public class Tag { 14 | 15 | @Id @GeneratedValue 16 | private Long id; 17 | 18 | @Column(unique = true, nullable = false) 19 | private String title; 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/tag/TagForm.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.tag; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class TagForm { 7 | 8 | private String tagTitle; 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/tag/TagRepository.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.tag; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | import org.springframework.transaction.annotation.Transactional; 5 | 6 | @Transactional(readOnly = true) 7 | public interface TagRepository extends JpaRepository { 8 | Tag findByTitle(String title); 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/tag/TagService.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.tag; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import org.springframework.stereotype.Service; 5 | import org.springframework.transaction.annotation.Transactional; 6 | 7 | @Service 8 | @Transactional 9 | @RequiredArgsConstructor 10 | public class TagService { 11 | 12 | private final TagRepository tagRepository; 13 | 14 | public Tag findOrCreateNew(String tagTitle) { 15 | Tag tag = tagRepository.findByTitle(tagTitle); 16 | if (tag == null) { 17 | tag = tagRepository.save(Tag.builder().title(tagTitle).build()); 18 | } 19 | return tag; 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/zone/Zone.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.zone; 2 | 3 | import lombok.*; 4 | 5 | import javax.persistence.*; 6 | 7 | @Entity 8 | @Getter @Setter @EqualsAndHashCode(of = "id") 9 | @Builder @AllArgsConstructor @NoArgsConstructor 10 | @Table(uniqueConstraints = @UniqueConstraint(columnNames = {"city", "province"})) 11 | public class Zone { 12 | 13 | @Id @GeneratedValue 14 | private Long id; 15 | 16 | @Column(nullable = false) 17 | private String city; 18 | 19 | @Column(nullable = false) 20 | private String localNameOfCity; 21 | 22 | @Column(nullable = true) 23 | private String province; 24 | 25 | @Override 26 | public String toString() { 27 | return String.format("%s(%s)/%s", city, localNameOfCity, province); 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/zone/ZoneForm.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.zone; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class ZoneForm { 7 | 8 | private String zoneName; 9 | 10 | public String getCityName() { 11 | return zoneName.substring(0, zoneName.indexOf("(")); 12 | } 13 | 14 | public String getProvinceName() { 15 | return zoneName.substring(zoneName.indexOf("/") + 1); 16 | } 17 | 18 | public String getLocalNameOfCity() { 19 | return zoneName.substring(zoneName.indexOf("(") + 1, zoneName.indexOf(")")); 20 | } 21 | 22 | public Zone getZone() { 23 | return Zone.builder().city(this.getCityName()) 24 | .localNameOfCity(this.getLocalNameOfCity()) 25 | .province(this.getProvinceName()).build(); 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/zone/ZoneRepository.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.zone; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | 5 | public interface ZoneRepository extends JpaRepository { 6 | Zone findByCityAndProvince(String cityName, String provinceName); 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/studyolle/modules/zone/ZoneService.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.zone; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import org.springframework.core.io.ClassPathResource; 5 | import org.springframework.core.io.Resource; 6 | import org.springframework.stereotype.Service; 7 | import org.springframework.transaction.annotation.Transactional; 8 | 9 | import java.io.IOException; 10 | import java.nio.charset.StandardCharsets; 11 | import java.nio.file.Files; 12 | import java.util.List; 13 | import java.util.stream.Collectors; 14 | 15 | @Service 16 | @Transactional 17 | @RequiredArgsConstructor 18 | public class ZoneService { 19 | 20 | private final ZoneRepository zoneRepository; 21 | 22 | // @PostConstruct 23 | public void initZoneData() throws IOException { 24 | if (zoneRepository.count() == 0) { 25 | Resource resource = new ClassPathResource("zones_kr.csv"); 26 | List zoneList = Files.readAllLines(resource.getFile().toPath(), StandardCharsets.UTF_8).stream() 27 | .map(line -> { 28 | String[] split = line.split(","); 29 | return Zone.builder().city(split[0]).localNameOfCity(split[1]).province(split[2]).build(); 30 | }).collect(Collectors.toList()); 31 | zoneRepository.saveAll(zoneList); 32 | } 33 | } 34 | 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/main/resources/application-dev.properties: -------------------------------------------------------------------------------- 1 | spring.jpa.hibernate.ddl-auto=validate 2 | 3 | spring.datasource.url=jdbc:postgresql://localhost:5432/studyolle_dev 4 | spring.datasource.username=admin 5 | spring.datasource.password=pass 6 | 7 | spring.mail.host=smtp.gmail.com 8 | spring.mail.port=587 9 | # 본인 gmail 계정으로 바꾸세요. 10 | spring.mail.username=studyolledev@gmail.com 11 | # 위에서 발급받은 App 패스워드로 바꾸세요. 12 | spring.mail.password=nsfuvmebghqpfudn 13 | spring.mail.properties.mail.smtp.auth=true 14 | spring.mail.properties.mail.smtp.timeout=5000 15 | spring.mail.properties.mail.smtp.starttls.enable=true 16 | -------------------------------------------------------------------------------- /src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.profiles.active=local 2 | 3 | # 개발할 때에만 create-drop 또는 update를 사용하고 운영 환경에서는 validate를 사용합니다. 4 | spring.jpa.hibernate.ddl-auto=create-drop 5 | 6 | # 개발시 SQL 로깅을 하여 어떤 값으로 어떤 SQL이 실행되는지 확인합니다. 7 | #spring.jpa.properties.hibernate.format_sql=true 8 | #logging.level.org.hibernate.SQL=DEBUG 9 | #logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE 10 | logging.level.sql=INFO 11 | 12 | # 톰캣 기본 요청 사이즈는 2MB 입니다. 그것보다 큰 요청을 받고 싶은 경우에 이 값을 조정해야 합니다. 13 | server.tomcat.max-http-form-post-size=5MB 14 | 15 | # 웹 서버 호스트 16 | app.host=http://localhost:8080 17 | 18 | # HTML
에서 th:method에서 PUT 또는 DELETE를 사용해서 보내는 _method를 사용해서 @PutMapping과 @DeleteMapping으로 요청을 맵핑. 19 | spring.mvc.hiddenmethod.filter.enabled=true 20 | 21 | # 빈 초기화 지연 22 | spring.main.lazy-initialization=true 23 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V202003210920__Add_online_zone.sql: -------------------------------------------------------------------------------- 1 | insert into zone (city, local_name_of_city, province, id) values ('online', '온라인', null, 86); -------------------------------------------------------------------------------- /src/main/resources/static/images/default_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackrslab/studyolle/7c21c5621834dd740fd7edbde46bd407031edf9e/src/main/resources/static/images/default_banner.png -------------------------------------------------------------------------------- /src/main/resources/static/images/logo_kr_horizontal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackrslab/studyolle/7c21c5621834dd740fd7edbde46bd407031edf9e/src/main/resources/static/images/logo_kr_horizontal.png -------------------------------------------------------------------------------- /src/main/resources/static/images/logo_symbol.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackrslab/studyolle/7c21c5621834dd740fd7edbde46bd407031edf9e/src/main/resources/static/images/logo_symbol.png -------------------------------------------------------------------------------- /src/main/resources/static/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "static", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "@yaireo/tagify": "^3.5.1", 13 | "bootstrap": "^4.4.1", 14 | "cropper": "^4.1.0", 15 | "font-awesome": "^4.7.0", 16 | "jdenticon": "^2.2.0", 17 | "jquery": "^3.4.1", 18 | "jquery-cropper": "^1.0.1", 19 | "mark.js": "^8.11.1", 20 | "moment": "^2.24.0", 21 | "summernote": "^0.8.16" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/resources/templates/account/check-email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 |
9 |

스터디올래 가입

10 | 11 |

your@email.com

12 |
13 | 14 |
15 |

스터디올래 가입

16 | 17 |

스터디올레 서비스를 사용하려면 인증 이메일을 확인하세요.

18 | 19 |
20 |

your@email.com

21 | 인증 이메일 다시 보내기 22 |
23 |
24 |
25 | 26 | -------------------------------------------------------------------------------- /src/main/resources/templates/account/check-login-email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 |
9 |

스터디올래 이메일 로그인

10 | 13 |

your@email.com

14 |
15 | 16 |
17 |

스터디올래 이메일 로그인

18 | 19 |

이메일을 확인하세요. 로그인 링크를 보냈습니다.

20 |

your@email.com

21 | 이메일을 확인해야 로그인 할 수 있습니다. 22 |
23 |
24 | 25 | -------------------------------------------------------------------------------- /src/main/resources/templates/account/checked-email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | 7 |
8 |

스터디올래 이메일 확인

9 | 12 |
13 | 14 |
15 |

스터디올래 이메일 확인

16 |

17 | 이메일을 확인했습니다. 10번째 회원, 18 | 백기선님 가입을 축하합니다. 19 |

20 | 이제부터 가입할 때 사용한 이메일 또는 닉네임과 패스트워드로 로그인 할 수 있습니다. 21 |
22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/main/resources/templates/account/email-login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 |
7 |
8 |

스터디올래

9 |

패스워드 없이 로그인하기

10 |
11 |
12 | 18 | 19 | 25 | 26 | 27 |
28 | 29 | 31 | 32 | 가입할 때 사용한 이메일을 입력하세요. 33 | 34 | 이메일을 입력하세요. 35 |
36 | 37 |
38 | 40 | 41 | 스터디올래에 처음 오신거라면 계정을 먼저 만드세요. 42 | 43 |
44 | 45 |
46 | 47 |
48 |
49 | 50 | 51 | -------------------------------------------------------------------------------- /src/main/resources/templates/account/logged-in-by-email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 |
9 |

스터디올래 이메일 로그인

10 | 13 |
14 | 15 |
16 |

스터디올래 이메일 로그인

17 |

이메일로 로그인 했습니다. 패스워드를 변경하세요.

18 |
19 |
20 | 21 | -------------------------------------------------------------------------------- /src/main/resources/templates/account/profile.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 |
7 |
8 |
9 |
10 | 11 | 13 | 16 |
17 |
18 |

Whiteship

19 |

bio

20 |

21 | 한 줄 소개를 추가하세요. 22 |

23 |
24 |
25 | 26 |
27 |
28 | 34 |
35 |
36 |
37 |
38 |

39 | 40 | 41 | 42 | 43 |

44 |

45 | 46 | 47 | 48 | 49 |

50 |

51 | 52 | 53 | 54 | 55 |

56 |

57 | 58 | 59 | 60 | 61 |

62 |

63 | 64 | 65 | 66 | 67 | 가입을 완료하려면 이메일을 확인하세요. 68 | 69 | 70 |

71 |
72 | 프로필 수정 73 |
74 |
75 |
76 | Study 77 |
78 |
79 |
80 |
81 |
82 | 83 | -------------------------------------------------------------------------------- /src/main/resources/templates/account/sign-up.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | 7 |
8 |
9 |

계정 만들기

10 |
11 |
12 |
14 |
15 | 16 | 18 | 19 | 공백없이 문자와 숫자로만 3자 이상 20자 이내로 입력하세요. 가입후에 변경할 수 있습니다. 20 | 21 | 닉네임을 입력하세요. 22 | Nickname Error 23 |
24 | 25 |
26 | 27 | 29 | 30 | 스터디올래는 사용자의 이메일을 공개하지 않습니다. 31 | 32 | 이메일을 입력하세요. 33 | Email Error 34 |
35 | 36 |
37 | 38 | 40 | 41 | 8자 이상 50자 이내로 입력하세요. 영문자, 숫자, 특수기호를 사용할 수 있으며 공백은 사용할 수 없습니다. 42 | 43 | 패스워드를 입력하세요. 44 | Password Error 45 |
46 | 47 |
48 | 50 | 51 | 약관에 동의하시면 가입하기 버튼을 클릭하세요. 52 | 53 |
54 |
55 |
56 | 57 | 58 |
59 | 60 | 61 | -------------------------------------------------------------------------------- /src/main/resources/templates/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 |
7 |

스터디올래

8 |

9 | 잘못된 요청입니다.
10 |

11 |

12 | 첫 페이지로 이동 13 |

14 |
15 |
16 | 17 | -------------------------------------------------------------------------------- /src/main/resources/templates/event/form.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 |
8 |
9 |
10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/main/resources/templates/event/update-form.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 |
8 |
10 |
11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/main/resources/templates/index-after-login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | 9 |
10 |
11 |
12 |
관심 스터디 주제
13 | 21 |
주요 활동 지역
22 | 30 |
31 |
32 |
참석할 모임이 없습니다.
33 |
참석할 모임
34 |
35 |
36 |
37 |
38 |
Event title
39 |
Study title
40 |

41 | 42 | 43 | Last updated 3 mins ago 44 | 45 |

46 | 모임 조회 47 | 스터디 조회 48 |
49 |
50 |
51 |
52 |
관련 스터디가 없습니다.
53 |
주요 활동 지역의 관심 주제 스터디
54 |
55 |
56 |
57 |
58 |
59 |
관리중인 스터디가 없습니다.
60 |
관리중인 스터디
61 | 67 | 68 |
참여중인 스터디가 없습니다.
69 |
참여중인 스터디
70 | 76 |
77 |
78 |
79 |
80 |
81 | 82 | -------------------------------------------------------------------------------- /src/main/resources/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 |
7 |
8 |

스터디올래

9 |

10 | 태그와 지역 기반으로 스터디를 찾고 참여하세요.
11 | 스터디 모임 관리 기능을 제공합니다. 12 |

13 |

14 | 회원 가입 15 |

16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | 26 | -------------------------------------------------------------------------------- /src/main/resources/templates/login.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 |
7 |
8 |
9 |

스터디올래

10 |

로그인

11 |
12 |
13 | 20 | 21 |
22 |
23 | 24 | 26 | 27 | 가입할 때 사용한 이메일 또는 닉네임을 입력하세요. 28 | 29 | 이메일을 입력하세요. 30 |
31 |
32 | 33 | 35 | 36 | 패스워드가 기억나지 않는다면, 패스워드 없이 로그인하기 37 | 38 | 패스워드를 입력하세요. 39 |
40 | 41 |
42 | 43 | 44 |
45 | 46 |
47 | 49 | 50 | 스터디올래에 처음 오신거라면 계정을 먼저 만드세요. 51 | 52 |
53 |
54 |
55 | 56 |
57 |
58 | 59 | 60 | -------------------------------------------------------------------------------- /src/main/resources/templates/mail/simple-link.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 스터디올래 6 | 7 | 8 |
9 |

안녕하세요.

10 | 11 |

메시지

12 | 13 |
14 | Link 15 |

링크가 동작하지 않는 경우에는 아래 URL을 웹브라우저에 복사해서 붙여 넣으세요.

16 | 17 |
18 |
19 |
20 | 스터디올래© 2020 21 |
22 | 23 | -------------------------------------------------------------------------------- /src/main/resources/templates/notification/list.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 |
8 |
9 | 21 | 22 | 39 | 40 |
    41 |
    42 | 45 | 삭제하지 않아도 한달이 지난 알림은 사라집니다. 46 |
    47 |
48 |
49 |
50 |
51 |
52 | 알림 메시지가 없습니다. 53 |
54 |
55 | 56 |
57 |
58 | 주요 활동 지역에 관심있는 주제의 스터디가 생겼습니다. 59 |
60 |
61 |
62 | 63 |
64 |
65 | 모임 참가 신청 관련 소식이 있습니다. 66 |
67 |
68 |
69 | 70 |
71 |
72 | 참여중인 스터디 관련 소식이 있습니다. 73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 | 81 | 82 | -------------------------------------------------------------------------------- /src/main/resources/templates/search.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 |
7 |
8 |

9 | 에 해당하는 스터디가 없습니다. 10 |

11 |

12 | 에 해당하는 스터디를 13 | 개 14 | 찾았습니다. 15 |

16 | 31 |
32 |
33 |
34 |
35 |
36 |
37 | 58 |
59 |
60 |
61 |
62 | 63 | 64 | 90 | 91 | -------------------------------------------------------------------------------- /src/main/resources/templates/settings/account.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 |
8 |
9 |
10 |
11 |
12 | 18 |
19 |

닉네임 변경

20 |
21 |
22 |
23 | 26 |
27 | 28 | 29 | 공백없이 문자와 숫자로만 3자 이상 20자 이내로 입력하세요. 가입후에 변경할 수 있습니다. 30 | 31 | 닉네임을 입력하세요. 32 | nickname Error 33 |
34 |
35 | 36 |
37 |
38 |
39 | 40 |
41 |
42 |

계정 삭제

43 | 46 | 47 |
48 |
49 |
50 |
51 | 52 |
53 |
54 | 55 | 56 | -------------------------------------------------------------------------------- /src/main/resources/templates/settings/notifications.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 |
8 |
9 |
10 |
11 |
12 | 18 |
19 |

알림 설정

20 |
21 |
22 |
23 | 27 |
28 |
29 | 30 | 31 |
32 |
33 | 34 | 35 |
36 |
37 | 40 |
41 |
42 | 43 | 44 |
45 |
46 | 47 | 48 |
49 |
50 | 53 |
54 |
55 | 56 | 57 |
58 |
59 | 60 | 61 |
62 |
63 |
64 | 65 |
66 |
67 |
68 |
69 |
70 | 71 |
72 |
73 | 74 | -------------------------------------------------------------------------------- /src/main/resources/templates/settings/password.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | 19 |
20 |

패스워드 변경

21 |
22 |
23 |
25 |
26 | 27 | 29 | 30 | 새 패스워드를 입력하세요. 31 | 32 | 패스워드를 입력하세요. 33 | New Password Error 34 |
35 | 36 |
37 | 38 | 40 | 41 | 새 패스워드를 다시 한번 입력하세요. 42 | 43 | 새 패스워드를 다시 입력하세요. 44 | New Password Confirm Error 45 |
46 | 47 |
48 | 49 |
50 |
51 |
52 |
53 |
54 |
55 | 56 | 57 | -------------------------------------------------------------------------------- /src/main/resources/templates/settings/tags.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |

관심있는 스터디 주제

14 |
15 |
16 |
17 | 21 | 22 | 24 |
25 |
26 |
27 |
28 |
29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/main/resources/templates/settings/zones.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |

주요 활동 지역

14 |
15 |
16 |
17 | 21 | 22 | 24 |
25 |
26 |
27 |
28 |
29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/main/resources/templates/study/events.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 |
8 |
9 |
10 |
11 |
12 | 24 |
25 |
26 | 새 모임이 없습니다. 27 |
28 |
29 |
30 |
31 | title 32 |
33 |
    34 |
  • 35 | 36 | 모임 시작 37 |
  • 38 |
  • 39 | 모집 마감, 40 | 41 | 명 모집 중 42 | ( 자리 남음) 43 | 44 |
  • 45 |
  • 46 | 자세히 보기 47 |
  • 48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 72 | 75 | 76 | 77 |
#지난 모임 이름모임 종료
1Title 70 | 71 | 73 | 자세히 보기 74 |
78 |
79 |
80 |
81 |
82 |
83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /src/main/resources/templates/study/form.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | 7 |
8 |
9 |

스터디 개설

10 |
11 |
12 |
13 |
14 | 15 | 17 | 18 | 공백없이 문자, 숫자, 대시(-)와 언더바(_)만 2자 이상 20자 이내로 입력하세요. 스터디 홈 주소에 사용합니다. 예) /study/study-path 19 | 20 | 스터디 경로를 입력하세요. 21 | Path Error 22 |
23 | 24 |
25 | 26 | 28 | 29 | 스터디 이름을 50자 이내로 입력하세요. 30 | 31 | 스터디 이름을 입력하세요. 32 | Title Error 33 |
34 | 35 |
36 | 37 | 39 | 40 | 100자 이내로 스터디를 짧은 소개해 주세요. 41 | 42 | 짧은 소개를 입력하세요. 43 | ShortDescription Error 44 |
45 | 46 |
47 | 48 | 50 | 51 | 스터디의 목표, 일정, 진행 방식, 사용할 교재 또는 인터넷 강좌 그리고 모집중인 스터디원 등 스터디에 대해 자세히 적어 주세요. 52 | 53 | 상세 소개를 입력하세요. 54 | FullDescription Error 55 |
56 | 57 |
58 | 60 |
61 |
62 |
63 | 64 | 65 |
66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /src/main/resources/templates/study/members.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 |
7 |
8 |
9 |
10 | 11 |
12 |
13 | 14 |
15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /src/main/resources/templates/study/settings/description.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
18 |
19 | 20 | 23 | 24 | 100자 이내로 스터디를 짧은 소개해 주세요. 25 | 26 | 짧은 소개를 입력하세요. 27 | ShortDescription Error 28 |
29 | 30 |
31 | 32 | 34 | 35 | 스터디의 목표, 일정, 진행 방식, 사용할 교재 또는 인터넷 강좌 그리고 모집중인 스터디원 등 스터디에 대해 자세히 적어 주세요. 36 | 37 | 상세 소개를 입력하세요. 38 | FullDescription Error 39 |
40 | 41 |
42 | 44 |
45 |
46 |
47 |
48 |
49 |
50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /src/main/resources/templates/study/settings/tags.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |

스터디 주제

17 |
18 |
19 |
20 | 23 | 25 | 27 |
28 |
29 |
30 |
31 |
32 |
33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/main/resources/templates/study/settings/zones.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |

주요 활동 지역

17 |
18 |
19 |
20 | 24 | 25 | 27 |
28 |
29 |
30 |
31 |
32 |
33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/main/resources/templates/study/view.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 |
7 |
8 |
9 |
10 | 11 |
12 |
13 |
14 | 15 |
16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /src/main/resources/zones_kr.csv: -------------------------------------------------------------------------------- 1 | Andong,안동시,North Gyeongsang 2 | Ansan,안산시,Gyeonggi 3 | Anseong,안성시,Gyeonggi 4 | Anyang,안양시,Gyeonggi 5 | Asan,아산시,South Chungcheong 6 | Boryeong,보령시,South Chungcheong 7 | Bucheon,부천시,Gyeonggi 8 | Busan,부산광역시,none 9 | Changwon,창원시,South Gyeongsang 10 | Cheonan,천안시,South Chungcheong 11 | Cheongju,청주시,North Chungcheong 12 | Chuncheon,춘천시,Gangwon 13 | Chungju,충주시,North Chungcheong 14 | Daegu,대구광역시,none 15 | Daejeon,대전광역시,none 16 | Dangjin,당진시,South Chungcheong 17 | Dongducheon,동두천시,Gyeonggi 18 | Donghae,동해시,Gangwon 19 | Gangneung,강릉시,Gangwon 20 | Geoje,거제시,South Gyeongsang 21 | Gimcheon,김천시,North Gyeongsang 22 | Gimhae,김해시,South Gyeongsang 23 | Gimje,김제시,North Jeolla 24 | Gimpo,김포시,Gyeonggi 25 | Gongju,공주시,South Chungcheong 26 | Goyang,고양시,Gyeonggi 27 | Gumi,구미시,North Gyeongsang 28 | Gunpo,군포시,Gyeonggi 29 | Gunsan,군산시,North Jeolla 30 | Guri,구리시,Gyeonggi 31 | Gwacheon,과천시,Gyeonggi 32 | Gwangju,광주광역시,none 33 | Gwangju,광주시,Gyeonggi 34 | Gwangmyeong,광명시,Gyeonggi 35 | Gwangyang,광양시,South Jeolla 36 | Gyeongju,경주시,North Gyeongsang 37 | Gyeongsan,경산시,North Gyeongsang 38 | Gyeryong,계룡시,South Chungcheong 39 | Hanam,하남시,Gyeonggi 40 | Hwaseong,화성시,Gyeonggi 41 | Icheon,이천시,Gyeonggi 42 | Iksan,익산시,North Jeolla 43 | Incheon,인천광역시,none 44 | Jecheon,제천시,North Chungcheong 45 | Jeongeup,정읍시,North Jeolla 46 | Jeonju,전주시,North Jeolla 47 | Jeju,제주시,Jeju 48 | Jinju,진주시,South Gyeongsang 49 | Naju,나주시,South Jeolla 50 | Namyangju,남양주시,Gyeonggi 51 | Namwon,남원시,North Jeolla 52 | Nonsan,논산시,South Chungcheong 53 | Miryang,밀양시,South Gyeongsang 54 | Mokpo,목포시,South Jeolla 55 | Mungyeong,문경시,North Gyeongsang 56 | Osan,오산시,Gyeonggi 57 | Paju,파주시,Gyeonggi 58 | Pocheon,포천시,Gyeonggi 59 | Pohang,포항시,North Gyeongsang 60 | Pyeongtaek,평택시,Gyeonggi 61 | Sacheon,사천시,South Gyeongsang 62 | Sangju,상주시,North Gyeongsang 63 | Samcheok,삼척시,Gangwon 64 | Sejong,세종특별자치시,none 65 | Seogwipo,서귀포시,Jeju 66 | Seongnam,성남시,Gyeonggi 67 | Seosan,서산시,South Chungcheong 68 | Seoul,서울특별시,none 69 | Siheung,시흥시,Gyeonggi 70 | Sokcho,속초시,Gangwon 71 | Suncheon,순천시,South Jeolla 72 | Suwon,수원시,Gyeonggi 73 | Taebaek,태백시,Gangwon 74 | Tongyeong,통영시,South Gyeongsang 75 | Uijeongbu,의정부시,Gyeonggi 76 | Uiwang,의왕시,Gyeonggi 77 | Ulsan,울산광역시,none 78 | Wonju,원주시,Gangwon 79 | Yangju,양주시,Gyeonggi 80 | Yangsan,양산시,South Gyeongsang 81 | Yeoju,여주시,Gyeonggi 82 | Yeongcheon,영천시,North Gyeongsang 83 | Yeongju,영주시,North Gyeongsang 84 | Yeosu,여수시,South Jeolla 85 | Yongin,용인시,Gyeonggi -------------------------------------------------------------------------------- /src/test/java/com/studyolle/PackageDependencyTests.java: -------------------------------------------------------------------------------- 1 | package com.studyolle; 2 | 3 | 4 | import com.tngtech.archunit.junit.AnalyzeClasses; 5 | import com.tngtech.archunit.junit.ArchTest; 6 | import com.tngtech.archunit.lang.ArchRule; 7 | 8 | import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; 9 | import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices; 10 | 11 | @AnalyzeClasses(packagesOf = App.class) 12 | public class PackageDependencyTests { 13 | 14 | private static final String STUDY = "..modules.study.."; 15 | private static final String EVENT = "..modules.event.."; 16 | private static final String ACCOUNT = "..modules.account.."; 17 | private static final String TAG = "..modules.tag.."; 18 | private static final String ZONE = "..modules.zone.."; 19 | private static final String MAIN = "..modules.main.."; 20 | 21 | @ArchTest 22 | ArchRule modulesPackageRule = classes().that().resideInAPackage("com.studyolle.modules..") 23 | .should().onlyBeAccessed().byClassesThat() 24 | .resideInAnyPackage("com.studyolle.modules.."); 25 | 26 | @ArchTest 27 | ArchRule studyPackageRule = classes().that().resideInAPackage(STUDY) 28 | .should().onlyBeAccessed().byClassesThat() 29 | .resideInAnyPackage(STUDY, EVENT, MAIN); 30 | 31 | @ArchTest 32 | ArchRule eventPackageRule = classes().that().resideInAPackage(EVENT) 33 | .should().accessClassesThat().resideInAnyPackage(STUDY, ACCOUNT, EVENT); 34 | 35 | @ArchTest 36 | ArchRule accountPackageRule = classes().that().resideInAPackage(ACCOUNT) 37 | .should().accessClassesThat().resideInAnyPackage(TAG, ZONE, ACCOUNT); 38 | 39 | @ArchTest 40 | ArchRule cycleCheck = slices().matching("com.studyolle.modules.(*)..") 41 | .should().beFreeOfCycles(); 42 | } 43 | -------------------------------------------------------------------------------- /src/test/java/com/studyolle/infra/ContainerBaseTest.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.infra; 2 | 3 | import org.testcontainers.containers.PostgreSQLContainer; 4 | 5 | public abstract class ContainerBaseTest { 6 | 7 | static final PostgreSQLContainer POSTGRE_SQL_CONTAINER; 8 | 9 | static { 10 | POSTGRE_SQL_CONTAINER = new PostgreSQLContainer<>("postgres:latest"); 11 | POSTGRE_SQL_CONTAINER.start(); 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/test/java/com/studyolle/infra/MockMvcTest.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.infra; 2 | 3 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; 4 | import org.springframework.boot.test.context.SpringBootTest; 5 | import org.springframework.test.context.ActiveProfiles; 6 | import org.springframework.transaction.annotation.Transactional; 7 | 8 | import java.lang.annotation.ElementType; 9 | import java.lang.annotation.Retention; 10 | import java.lang.annotation.RetentionPolicy; 11 | import java.lang.annotation.Target; 12 | 13 | @Retention(RetentionPolicy.RUNTIME) 14 | @Target(ElementType.TYPE) 15 | @ActiveProfiles("test") 16 | @Transactional 17 | @SpringBootTest 18 | @AutoConfigureMockMvc 19 | public @interface MockMvcTest { 20 | } 21 | -------------------------------------------------------------------------------- /src/test/java/com/studyolle/modules/account/AccountControllerTest.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.account; 2 | 3 | import com.studyolle.infra.ContainerBaseTest; 4 | import com.studyolle.infra.MockMvcTest; 5 | import com.studyolle.infra.mail.EmailMessage; 6 | import com.studyolle.infra.mail.EmailService; 7 | import org.junit.jupiter.api.DisplayName; 8 | import org.junit.jupiter.api.Test; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.boot.test.mock.mockito.MockBean; 11 | import org.springframework.test.web.servlet.MockMvc; 12 | 13 | import static org.junit.jupiter.api.Assertions.assertNotEquals; 14 | import static org.junit.jupiter.api.Assertions.assertNotNull; 15 | import static org.mockito.ArgumentMatchers.any; 16 | import static org.mockito.BDDMockito.then; 17 | import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; 18 | import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; 19 | import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated; 20 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 21 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 22 | import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; 23 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; 24 | 25 | @MockMvcTest 26 | class AccountControllerTest extends ContainerBaseTest { 27 | 28 | @Autowired MockMvc mockMvc; 29 | @Autowired AccountRepository accountRepository; 30 | 31 | @MockBean 32 | EmailService emailService; 33 | 34 | @DisplayName("인증 메일 확인 - 입력값 오류") 35 | @Test 36 | void checkEmailToken_with_wrong_input() throws Exception { 37 | mockMvc.perform(get("/check-email-token") 38 | .param("token", "sdfjslwfwef") 39 | .param("email", "email@email.com")) 40 | .andExpect(status().isOk()) 41 | .andExpect(model().attributeExists("error")) 42 | .andExpect(view().name("account/checked-email")) 43 | .andExpect(unauthenticated()); 44 | } 45 | 46 | @DisplayName("인증 메일 확인 - 입력값 정상") 47 | @Test 48 | void checkEmailToken() throws Exception { 49 | Account account = Account.builder() 50 | .email("test@email.com") 51 | .password("12345678") 52 | .nickname("keesun") 53 | .build(); 54 | Account newAccount = accountRepository.save(account); 55 | newAccount.generateEmailCheckToken(); 56 | 57 | mockMvc.perform(get("/check-email-token") 58 | .param("token", newAccount.getEmailCheckToken()) 59 | .param("email", newAccount.getEmail())) 60 | .andExpect(status().isOk()) 61 | .andExpect(model().attributeDoesNotExist("error")) 62 | .andExpect(model().attributeExists("nickname")) 63 | .andExpect(model().attributeExists("numberOfUser")) 64 | .andExpect(view().name("account/checked-email")) 65 | .andExpect(authenticated().withUsername("keesun")); 66 | } 67 | 68 | @DisplayName("회원 가입 화면 보이는지 테스트") 69 | @Test 70 | void signUpForm() throws Exception { 71 | mockMvc.perform(get("/sign-up")) 72 | .andDo(print()) 73 | .andExpect(status().isOk()) 74 | .andExpect(view().name("account/sign-up")) 75 | .andExpect(model().attributeExists("signUpForm")) 76 | .andExpect(unauthenticated()); 77 | } 78 | 79 | @DisplayName("회원 가입 처리 - 입력값 오류") 80 | @Test 81 | void signUpSubmit_with_wrong_input() throws Exception { 82 | mockMvc.perform(post("/sign-up") 83 | .param("nickname", "keesun") 84 | .param("email", "email..") 85 | .param("password", "12345") 86 | .with(csrf())) 87 | .andExpect(status().isOk()) 88 | .andExpect(view().name("account/sign-up")) 89 | .andExpect(unauthenticated()); 90 | } 91 | 92 | @DisplayName("회원 가입 처리 - 입력값 정상") 93 | @Test 94 | void signUpSubmit_with_correct_input() throws Exception { 95 | mockMvc.perform(post("/sign-up") 96 | .param("nickname", "keesun") 97 | .param("email", "keesun@email.com") 98 | .param("password", "12345678") 99 | .with(csrf())) 100 | .andExpect(status().is3xxRedirection()) 101 | .andExpect(view().name("redirect:/")) 102 | .andExpect(authenticated().withUsername("keesun")); 103 | 104 | Account account = accountRepository.findByEmail("keesun@email.com"); 105 | assertNotNull(account); 106 | assertNotEquals(account.getPassword(), "12345678"); 107 | assertNotNull(account.getEmailCheckToken()); 108 | then(emailService).should().sendEmail(any(EmailMessage.class)); 109 | } 110 | 111 | } -------------------------------------------------------------------------------- /src/test/java/com/studyolle/modules/account/AccountFactory.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.account; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import org.springframework.beans.factory.annotation.Autowired; 5 | import org.springframework.stereotype.Component; 6 | 7 | @Component 8 | @RequiredArgsConstructor 9 | public class AccountFactory { 10 | 11 | @Autowired AccountRepository accountRepository; 12 | 13 | public Account createAccount(String nickname) { 14 | Account whiteship = new Account(); 15 | whiteship.setNickname(nickname); 16 | whiteship.setEmail(nickname + "@email.com"); 17 | accountRepository.save(whiteship); 18 | return whiteship; 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/test/java/com/studyolle/modules/account/WithAccount.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.account; 2 | 3 | import org.springframework.security.test.context.support.WithSecurityContext; 4 | 5 | import java.lang.annotation.Retention; 6 | import java.lang.annotation.RetentionPolicy; 7 | 8 | @Retention(RetentionPolicy.RUNTIME) 9 | @WithSecurityContext(factory = WithAccountSecurityContextFacotry.class) 10 | public @interface WithAccount { 11 | 12 | String value(); 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/test/java/com/studyolle/modules/account/WithAccountSecurityContextFacotry.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.account; 2 | 3 | import com.studyolle.modules.account.form.SignUpForm; 4 | import lombok.RequiredArgsConstructor; 5 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 6 | import org.springframework.security.core.Authentication; 7 | import org.springframework.security.core.context.SecurityContext; 8 | import org.springframework.security.core.context.SecurityContextHolder; 9 | import org.springframework.security.core.userdetails.UserDetails; 10 | import org.springframework.security.test.context.support.WithSecurityContextFactory; 11 | 12 | @RequiredArgsConstructor 13 | public class WithAccountSecurityContextFacotry implements WithSecurityContextFactory { 14 | 15 | private final AccountService accountService; 16 | 17 | @Override 18 | public SecurityContext createSecurityContext(WithAccount withAccount) { 19 | String nickname = withAccount.value(); 20 | 21 | SignUpForm signUpForm = new SignUpForm(); 22 | signUpForm.setNickname(nickname); 23 | signUpForm.setEmail(nickname + "@email.com"); 24 | signUpForm.setPassword("12345678"); 25 | accountService.processNewAccount(signUpForm); 26 | 27 | UserDetails principal = accountService.loadUserByUsername(nickname); 28 | Authentication authentication = new UsernamePasswordAuthenticationToken(principal, principal.getPassword(), principal.getAuthorities()); 29 | SecurityContext context = SecurityContextHolder.createEmptyContext(); 30 | context.setAuthentication(authentication); 31 | return context; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/test/java/com/studyolle/modules/main/MainControllerTest.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.main; 2 | 3 | import com.studyolle.infra.ContainerBaseTest; 4 | import com.studyolle.infra.MockMvcTest; 5 | import com.studyolle.modules.account.AccountRepository; 6 | import com.studyolle.modules.account.AccountService; 7 | import com.studyolle.modules.account.WithAccount; 8 | import com.studyolle.modules.account.form.SignUpForm; 9 | import org.junit.jupiter.api.AfterEach; 10 | import org.junit.jupiter.api.BeforeEach; 11 | import org.junit.jupiter.api.DisplayName; 12 | import org.junit.jupiter.api.Test; 13 | import org.springframework.beans.factory.annotation.Autowired; 14 | import org.springframework.security.test.context.support.WithMockUser; 15 | import org.springframework.test.web.servlet.MockMvc; 16 | 17 | import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; 18 | import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; 19 | import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated; 20 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 21 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 22 | import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; 23 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; 24 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 25 | 26 | @MockMvcTest 27 | class MainControllerTest extends ContainerBaseTest { 28 | 29 | @Autowired MockMvc mockMvc; 30 | @Autowired AccountService accountService; 31 | @Autowired AccountRepository accountRepository; 32 | 33 | @BeforeEach 34 | void beforeEach() { 35 | SignUpForm signUpForm = new SignUpForm(); 36 | signUpForm.setNickname("keesun"); 37 | signUpForm.setEmail("keesun@email.com"); 38 | signUpForm.setPassword("12345678"); 39 | accountService.processNewAccount(signUpForm); 40 | } 41 | 42 | @AfterEach 43 | void afterEach() { 44 | accountRepository.deleteAll(); 45 | } 46 | 47 | @DisplayName("이메일로 로그인 성공") 48 | @Test 49 | void login_with_email() throws Exception { 50 | mockMvc.perform(post("/login") 51 | .param("username", "keesun@email.com") 52 | .param("password", "12345678") 53 | .with(csrf())) 54 | .andExpect(status().is3xxRedirection()) 55 | .andExpect(redirectedUrl("/")) 56 | .andExpect(authenticated().withUsername("keesun")); 57 | } 58 | 59 | @DisplayName("이메일로 로그인 성공") 60 | @Test 61 | void login_with_nickname() throws Exception { 62 | mockMvc.perform(post("/login") 63 | .param("username", "keesun") 64 | .param("password", "12345678") 65 | .with(csrf())) 66 | .andExpect(status().is3xxRedirection()) 67 | .andExpect(redirectedUrl("/")) 68 | .andExpect(authenticated().withUsername("keesun")); 69 | } 70 | 71 | @DisplayName("로그인 실패") 72 | @Test 73 | void login_fail() throws Exception { 74 | mockMvc.perform(post("/login") 75 | .param("username", "111111") 76 | .param("password", "000000000") 77 | .with(csrf())) 78 | .andExpect(status().is3xxRedirection()) 79 | .andExpect(redirectedUrl("/login?error")) 80 | .andExpect(unauthenticated()); 81 | } 82 | 83 | @WithMockUser 84 | @DisplayName("로그아웃") 85 | @Test 86 | void logout() throws Exception { 87 | mockMvc.perform(post("/logout") 88 | .with(csrf())) 89 | .andExpect(status().is3xxRedirection()) 90 | .andExpect(redirectedUrl("/")) 91 | .andExpect(unauthenticated()); 92 | } 93 | 94 | @WithAccount("whiteship") 95 | @DisplayName("인증된 사용자가 로그인 페이지 접근시 에러") 96 | @Test 97 | void login_access_fail() throws Exception { 98 | mockMvc.perform(get("/login")) 99 | .andDo(print()) 100 | .andExpect(status().isForbidden()); 101 | } 102 | 103 | } -------------------------------------------------------------------------------- /src/test/java/com/studyolle/modules/study/StudyControllerTest.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.study; 2 | 3 | import com.studyolle.infra.ContainerBaseTest; 4 | import com.studyolle.infra.MockMvcTest; 5 | import com.studyolle.modules.account.Account; 6 | import com.studyolle.modules.account.AccountFactory; 7 | import com.studyolle.modules.account.AccountRepository; 8 | import com.studyolle.modules.account.WithAccount; 9 | import org.junit.jupiter.api.DisplayName; 10 | import org.junit.jupiter.api.Test; 11 | import org.springframework.beans.factory.annotation.Autowired; 12 | import org.springframework.test.web.servlet.MockMvc; 13 | 14 | import static org.junit.jupiter.api.Assertions.*; 15 | import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; 16 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 17 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 18 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; 19 | 20 | @MockMvcTest 21 | public class StudyControllerTest extends ContainerBaseTest { 22 | 23 | @Autowired MockMvc mockMvc; 24 | @Autowired StudyService studyService; 25 | @Autowired StudyRepository studyRepository; 26 | @Autowired AccountRepository accountRepository; 27 | @Autowired AccountFactory accountFactory; 28 | @Autowired StudyFactory studyFactory; 29 | 30 | @Test 31 | @WithAccount("keesun") 32 | @DisplayName("스터디 개설 폼 조회") 33 | void createStudyForm() throws Exception { 34 | mockMvc.perform(get("/new-study")) 35 | .andExpect(status().isOk()) 36 | .andExpect(view().name("study/form")) 37 | .andExpect(model().attributeExists("account")) 38 | .andExpect(model().attributeExists("studyForm")); 39 | } 40 | 41 | @Test 42 | @WithAccount("keesun") 43 | @DisplayName("스터디 개설 - 완료") 44 | void createStudy_success() throws Exception { 45 | mockMvc.perform(post("/new-study") 46 | .param("path", "test-path") 47 | .param("title", "study title") 48 | .param("shortDescription", "short description of a study") 49 | .param("fullDescription", "full description of a study") 50 | .with(csrf())) 51 | .andExpect(status().is3xxRedirection()) 52 | .andExpect(redirectedUrl("/study/test-path")); 53 | 54 | Study study = studyRepository.findByPath("test-path"); 55 | assertNotNull(study); 56 | Account account = accountRepository.findByNickname("keesun"); 57 | assertTrue(study.getManagers().contains(account)); 58 | } 59 | 60 | @Test 61 | @WithAccount("keesun") 62 | @DisplayName("스터디 개설 - 실패") 63 | void createStudy_fail() throws Exception { 64 | mockMvc.perform(post("/new-study") 65 | .param("path", "wrong path") 66 | .param("title", "study title") 67 | .param("shortDescription", "short description of a study") 68 | .param("fullDescription", "full description of a study") 69 | .with(csrf())) 70 | .andExpect(status().isOk()) 71 | .andExpect(view().name("study/form")) 72 | .andExpect(model().hasErrors()) 73 | .andExpect(model().attributeExists("studyForm")) 74 | .andExpect(model().attributeExists("account")); 75 | 76 | Study study = studyRepository.findByPath("test-path"); 77 | assertNull(study); 78 | } 79 | 80 | @Test 81 | @WithAccount("keesun") 82 | @DisplayName("스터디 조회") 83 | void viewStudy() throws Exception { 84 | Study study = new Study(); 85 | study.setPath("test-path"); 86 | study.setTitle("test study"); 87 | study.setShortDescription("short description"); 88 | study.setFullDescription("

full description

"); 89 | 90 | Account keesun = accountRepository.findByNickname("keesun"); 91 | studyService.createNewStudy(study, keesun); 92 | 93 | mockMvc.perform(get("/study/test-path")) 94 | .andExpect(view().name("study/view")) 95 | .andExpect(model().attributeExists("account")) 96 | .andExpect(model().attributeExists("study")); 97 | } 98 | 99 | @Test 100 | @WithAccount("keesun") 101 | @DisplayName("스터디 가입") 102 | void joinStudy() throws Exception { 103 | Account whiteship = accountFactory.createAccount("whiteship"); 104 | Study study = studyFactory.createStudy("test-study", whiteship); 105 | 106 | mockMvc.perform(get("/study/" + study.getPath() + "/join")) 107 | .andExpect(status().is3xxRedirection()) 108 | .andExpect(redirectedUrl("/study/" + study.getPath() + "/members")); 109 | 110 | Account keesun = accountRepository.findByNickname("keesun"); 111 | assertTrue(study.getMembers().contains(keesun)); 112 | } 113 | 114 | @Test 115 | @WithAccount("keesun") 116 | @DisplayName("스터디 탈퇴") 117 | void leaveStudy() throws Exception { 118 | Account whiteship = accountFactory.createAccount("whiteship"); 119 | Study study = studyFactory.createStudy("test-study", whiteship); 120 | Account keesun = accountRepository.findByNickname("keesun"); 121 | studyService.addMember(study, keesun); 122 | 123 | mockMvc.perform(get("/study/" + study.getPath() + "/leave")) 124 | .andExpect(status().is3xxRedirection()) 125 | .andExpect(redirectedUrl("/study/" + study.getPath() + "/members")); 126 | 127 | assertFalse(study.getMembers().contains(keesun)); 128 | } 129 | 130 | 131 | 132 | 133 | 134 | } -------------------------------------------------------------------------------- /src/test/java/com/studyolle/modules/study/StudyFactory.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.study; 2 | 3 | import com.studyolle.modules.account.Account; 4 | import lombok.RequiredArgsConstructor; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.stereotype.Component; 7 | 8 | @Component 9 | @RequiredArgsConstructor 10 | public class StudyFactory { 11 | 12 | @Autowired StudyService studyService; 13 | @Autowired StudyRepository studyRepository; 14 | 15 | public Study createStudy(String path, Account manager) { 16 | Study study = new Study(); 17 | study.setPath(path); 18 | studyService.createNewStudy(study, manager); 19 | return study; 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/test/java/com/studyolle/modules/study/StudySettingsControllerTest.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.study; 2 | 3 | import com.studyolle.infra.ContainerBaseTest; 4 | import com.studyolle.infra.MockMvcTest; 5 | import com.studyolle.modules.account.Account; 6 | import com.studyolle.modules.account.AccountFactory; 7 | import com.studyolle.modules.account.AccountRepository; 8 | import com.studyolle.modules.account.WithAccount; 9 | import org.junit.jupiter.api.DisplayName; 10 | import org.junit.jupiter.api.Test; 11 | import org.springframework.beans.factory.annotation.Autowired; 12 | import org.springframework.test.web.servlet.MockMvc; 13 | 14 | import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; 15 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 16 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 17 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; 18 | 19 | @MockMvcTest 20 | class StudySettingsControllerTest extends ContainerBaseTest { 21 | 22 | @Autowired MockMvc mockMvc; 23 | @Autowired StudyFactory studyFactory; 24 | @Autowired AccountFactory accountFactory; 25 | @Autowired AccountRepository accountRepository; 26 | @Autowired StudyRepository studyRepository; 27 | 28 | @Test 29 | @WithAccount("keesun") 30 | @DisplayName("스터디 소개 수정 폼 조회 - 실패 (권한 없는 유저)") 31 | void updateDescriptionForm_fail() throws Exception { 32 | Account whiteship = accountFactory.createAccount("whiteship"); 33 | Study study = studyFactory.createStudy("test-study", whiteship); 34 | 35 | mockMvc.perform(get("/study/" + study.getPath() + "/settings/description")) 36 | .andExpect(status().isOk()) 37 | .andExpect(view().name("error")); 38 | } 39 | 40 | @Test 41 | @WithAccount("keesun") 42 | @DisplayName("스터디 소개 수정 폼 조회 - 성공") 43 | void updateDescriptionForm_success() throws Exception { 44 | Account keesun = accountRepository.findByNickname("keesun"); 45 | Study study = studyFactory.createStudy("test-study", keesun); 46 | 47 | mockMvc.perform(get("/study/" + study.getPath() + "/settings/description")) 48 | .andExpect(status().isOk()) 49 | .andExpect(view().name("study/settings/description")) 50 | .andExpect(model().attributeExists("studyDescriptionForm")) 51 | .andExpect(model().attributeExists("account")) 52 | .andExpect(model().attributeExists("study")); 53 | } 54 | 55 | @Test 56 | @WithAccount("keesun") 57 | @DisplayName("스터디 소개 수정 - 성공") 58 | void updateDescription_success() throws Exception { 59 | Account keesun = accountRepository.findByNickname("keesun"); 60 | Study study = studyFactory.createStudy("test-study", keesun); 61 | 62 | String settingsDescriptionUrl = "/study/" + study.getPath() + "/settings/description"; 63 | mockMvc.perform(post(settingsDescriptionUrl) 64 | .param("shortDescription", "short description") 65 | .param("fullDescription", "full description") 66 | .with(csrf())) 67 | .andExpect(status().is3xxRedirection()) 68 | .andExpect(redirectedUrl(settingsDescriptionUrl)) 69 | .andExpect(flash().attributeExists("message")); 70 | } 71 | 72 | @Test 73 | @WithAccount("keesun") 74 | @DisplayName("스터디 소개 수정 - 실패") 75 | void updateDescription_fail() throws Exception { 76 | Account keesun = accountRepository.findByNickname("keesun"); 77 | Study study = studyFactory.createStudy("test-study", keesun); 78 | 79 | String settingsDescriptionUrl = "/study/" + study.getPath() + "/settings/description"; 80 | mockMvc.perform(post(settingsDescriptionUrl) 81 | .param("shortDescription", "") 82 | .param("fullDescription", "full description") 83 | .with(csrf())) 84 | .andExpect(status().isOk()) 85 | .andExpect(model().hasErrors()) 86 | .andExpect(model().attributeExists("studyDescriptionForm")) 87 | .andExpect(model().attributeExists("study")) 88 | .andExpect(model().attributeExists("account")); 89 | } 90 | 91 | } -------------------------------------------------------------------------------- /src/test/java/com/studyolle/modules/study/StudyTest.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.study; 2 | 3 | import com.studyolle.modules.account.Account; 4 | import com.studyolle.modules.account.UserAccount; 5 | import org.junit.jupiter.api.BeforeEach; 6 | import org.junit.jupiter.api.DisplayName; 7 | import org.junit.jupiter.api.Test; 8 | 9 | import static org.junit.jupiter.api.Assertions.assertFalse; 10 | import static org.junit.jupiter.api.Assertions.assertTrue; 11 | 12 | class StudyTest { 13 | 14 | Study study; 15 | Account account; 16 | UserAccount userAccount; 17 | 18 | @BeforeEach 19 | void beforeEach() { 20 | study = new Study(); 21 | account = new Account(); 22 | account.setNickname("keesun"); 23 | account.setPassword("123"); 24 | userAccount = new UserAccount(account); 25 | 26 | } 27 | 28 | @DisplayName("스터디를 공개했고 인원 모집 중이고, 이미 멤버나 스터디 관리자가 아니라면 스터디 가입 가능") 29 | @Test 30 | void isJoinable() { 31 | study.setPublished(true); 32 | study.setRecruiting(true); 33 | 34 | assertTrue(study.isJoinable(userAccount)); 35 | } 36 | 37 | @DisplayName("스터디를 공개했고 인원 모집 중이더라도, 스터디 관리자는 스터디 가입이 불필요하다.") 38 | @Test 39 | void isJoinable_false_for_manager() { 40 | study.setPublished(true); 41 | study.setRecruiting(true); 42 | study.addManager(account); 43 | 44 | assertFalse(study.isJoinable(userAccount)); 45 | } 46 | 47 | @DisplayName("스터디를 공개했고 인원 모집 중이더라도, 스터디 멤버는 스터디 재가입이 불필요하다.") 48 | @Test 49 | void isJoinable_false_for_member() { 50 | study.setPublished(true); 51 | study.setRecruiting(true); 52 | study.addMemeber(account); 53 | 54 | assertFalse(study.isJoinable(userAccount)); 55 | } 56 | 57 | @DisplayName("스터디가 비공개거나 인원 모집 중이 아니면 스터디 가입이 불가능하다.") 58 | @Test 59 | void isJoinable_false_for_non_recruiting_study() { 60 | study.setPublished(true); 61 | study.setRecruiting(false); 62 | 63 | assertFalse(study.isJoinable(userAccount)); 64 | 65 | study.setPublished(false); 66 | study.setRecruiting(true); 67 | 68 | assertFalse(study.isJoinable(userAccount)); 69 | } 70 | 71 | @DisplayName("스터디 관리자인지 확인") 72 | @Test 73 | void isManager() { 74 | study.addManager(account); 75 | assertTrue(study.isManager(userAccount)); 76 | } 77 | 78 | @DisplayName("스터디 멤버인지 확인") 79 | @Test 80 | void isMember() { 81 | study.addMemeber(account); 82 | assertTrue(study.isMember(userAccount)); 83 | } 84 | 85 | } -------------------------------------------------------------------------------- /src/test/java/com/studyolle/modules/tag/TagRepositoryTest.java: -------------------------------------------------------------------------------- 1 | package com.studyolle.modules.tag; 2 | 3 | import com.studyolle.infra.ContainerBaseTest; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; 7 | 8 | import java.util.List; 9 | 10 | import static org.junit.jupiter.api.Assertions.assertEquals; 11 | 12 | @DataJpaTest 13 | class TagRepositoryTest extends ContainerBaseTest { 14 | 15 | @Autowired TagRepository tagRepository; 16 | 17 | @Test 18 | void findAll() { 19 | Tag spring = Tag.builder().title("spring").build(); 20 | tagRepository.save(spring); 21 | 22 | Tag hibernate = Tag.builder().title("hibernate").build(); 23 | tagRepository.save(hibernate); 24 | 25 | List tags = tagRepository.findAll(); 26 | assertEquals(2, tags.size()); 27 | } 28 | 29 | } -------------------------------------------------------------------------------- /src/test/resources/application-test.properties: -------------------------------------------------------------------------------- 1 | spring.jpa.hibernate.ddl-auto=update 2 | 3 | spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver 4 | spring.datasource.url=jdbc:tc:postgresql:///studytest 5 | --------------------------------------------------------------------------------