Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(agreement): change to manage agreements dynamically #747

Merged
merged 15 commits into from
May 19, 2024
Merged
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions frontend/src/api/agreements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import axios from "axios";

export type AgreementResponse = {
id: number;
version: number;
content: string;
};

export const fetchAgreement = () => axios.get<AgreementResponse>("/api/agreements/latest");
14 changes: 13 additions & 1 deletion frontend/src/components/Header/Header.tsx
Original file line number Diff line number Diff line change
@@ -7,6 +7,8 @@ import styles from "./Header.module.css";
import { PATH } from "../../constants/path";
import { ValueOf } from "../../../types/utility";
import MemberIcon from "../../assets/icon/member-icon.svg";
import { fetchAgreement } from "../../api/agreements";
import { ERROR_MESSAGE } from "../../constants/messages";

const Header = () => {
const navigate = useNavigate();
@@ -30,6 +32,16 @@ const Header = () => {
navigate(PATH.HOME);
};

const goToSignUp = async () => {
try {
const agreement = await fetchAgreement();

navigate(PATH.SIGN_UP, { state: { agreement } });
} catch (error) {
alert(ERROR_MESSAGE.API.LOAD_AGREEMENT);
}
};

return (
<div className={styles.box}>
<header className={styles.header}>
@@ -92,7 +104,7 @@ const Header = () => {
<>
<Link to={PATH.LOGIN}>로그인</Link>
<div className={styles.bar} />
<Link to={PATH.SIGN_UP}>회원가입</Link>
<a onClick={goToSignUp}>회원가입</a>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

c: href 없는 a tag로 바꾸면서 cursor가 pointer가 아닌 text 가 되었네요.
css 설정을 하거나 href="#" 넣고 이벤트를 막는 처리가 들어가면 좋을 것 같습니다! (어떤게 정석이죠?ㅋㅋ)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오! 좋습니다. 제가 놓친 부분이에요 ㅎㅎ
여러 부분 고민했는데 Link에도 href="#"를 넣고 사용할 수 있네요 ㅎㅎ
확인해 보니 브라우저 접근성 측면에서도 href="#" 처리가 좀 더 나아 보입니다!

일관성을 맞추기 위해서 이 부분 코드 수정해서 반영해둘게요!

</>
)}
</div>
1 change: 1 addition & 0 deletions frontend/src/constants/messages.ts
Original file line number Diff line number Diff line change
@@ -33,6 +33,7 @@ export const ERROR_MESSAGE = {
EDIT_PASSWORD: "입력된 정보가 올바르지 않습니다. 다시 확인해 주세요.",
LOAD_APPLICATION_FORM: "지원서를 불러오는 데 실패했습니다. 잠시 후 다시 시도해 주세요.",
SAVE_APPLICATION_FORM: "지원서를 저장하는 데 실패했습니다. 잠시 후 다시 시도해 주세요.",
LOAD_AGREEMENT: "동의 약관을 불러오는 데 실패했습니다. 잠시 후 다시 시도해 주세요.",
},
ACCESS: {
REQUIRED_LOGIN: "로그인이 필요합니다.",
21 changes: 17 additions & 4 deletions frontend/src/pages/SignUp/SignUp.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import Button from "../../components/@common/Button/Button";
import Container, {
CONTAINER_SIZE,
@@ -15,14 +15,15 @@ import SummaryCheckField from "../../components/form/SummaryCheckField/SummaryCh
import { FORM } from "../../constants/form";
import { ERROR_MESSAGE } from "../../constants/messages";
import { PATH } from "../../constants/path";
import { POLICY_SUMMARY } from "../../constants/policySummary";
import useSignUpForm, { SIGN_UP_FORM_NAME } from "../../hooks/useSignUpForm";
import useTokenContext from "../../hooks/useTokenContext";
import styles from "./SignUp.module.css";

const SignUp = () => {
const navigate = useNavigate();
const location = useLocation();
const { postRegister } = useTokenContext();
const [agreementContent, setAgreementContent] = useState("");

const [emailStatus, setEmailStatus] = useState(EMAIL_STATUS.INPUT);
const {
@@ -53,6 +54,15 @@ const SignUp = () => {
}
};

useEffect(() => {
if (!location?.state?.agreement) {
navigate(PATH.RECRUITS);
return;
}

setAgreementContent(location.state.agreement?.data?.content);
}, [location.state]);

return (
<Container title="회원가입" titleAlign={TITLE_ALIGN.LEFT} size={CONTAINER_SIZE.NARROW}>
<Form onSubmit={handleSubmit}>
@@ -63,7 +73,10 @@ const SignUp = () => {
onChange={handleChanges[SIGN_UP_FORM_NAME.IS_TERM_AGREED]}
required
>
<p className={styles["summary-content"]}>{POLICY_SUMMARY}</p>
<p
className={styles["summary-content"]}
dangerouslySetInnerHTML={{ __html: agreementContent }}
/>
</SummaryCheckField>

<EmailField
5 changes: 5 additions & 0 deletions src/docs/asciidoc/agreement.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
= 동의서 관련 API

== 최신 버전의 동의서 조회

operation::agreement-latest-get[snippets='http-request,http-response']
3 changes: 2 additions & 1 deletion src/docs/asciidoc/index.adoc
Original file line number Diff line number Diff line change
@@ -6,9 +6,10 @@

== Service-Apply

include::agreement.adoc[]
include::application-form.adoc[]
include::assignment.adoc[]
include::judgment.adoc[]
include::member.adoc[]
include::mission.adoc[]
include::recruitment.adoc[]
include::member.adoc[]
7 changes: 7 additions & 0 deletions src/main/kotlin/apply/application/AgreementResponse.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package apply.application

import apply.domain.agreement.Agreement

data class AgreementResponse(val id: Long, val version: Int, val content: String) {
constructor(agreement: Agreement) : this(agreement.id, agreement.version, agreement.content)
}
14 changes: 14 additions & 0 deletions src/main/kotlin/apply/application/AgreementService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package apply.application

import apply.domain.agreement.AgreementRepository
import apply.domain.agreement.getFirstByOrderByVersionDesc
import org.springframework.stereotype.Service

@Service
class AgreementService(
private val agreementRepository: AgreementRepository,
) {
fun latest(): AgreementResponse {
return agreementRepository.getFirstByOrderByVersionDesc().let(::AgreementResponse)
}
}
27 changes: 22 additions & 5 deletions src/main/kotlin/apply/config/DatabaseInitializer.kt
Original file line number Diff line number Diff line change
@@ -2,6 +2,8 @@ package apply.config

import apply.domain.administrator.Administrator
import apply.domain.administrator.AdministratorRepository
import apply.domain.agreement.Agreement
import apply.domain.agreement.AgreementRepository
import apply.domain.applicationform.ApplicationForm
import apply.domain.applicationform.ApplicationFormAnswer
import apply.domain.applicationform.ApplicationFormAnswers
@@ -22,6 +24,10 @@ import apply.domain.judgmentitem.JudgmentItemRepository
import apply.domain.judgmentitem.ProgrammingLanguage
import apply.domain.mail.MailHistory
import apply.domain.mail.MailHistoryRepository
import apply.domain.member.Gender
import apply.domain.member.Member
import apply.domain.member.MemberRepository
import apply.domain.member.Password
import apply.domain.mission.Mission
import apply.domain.mission.MissionRepository
import apply.domain.recruitment.Recruitment
@@ -30,22 +36,20 @@ import apply.domain.recruitmentitem.RecruitmentItem
import apply.domain.recruitmentitem.RecruitmentItemRepository
import apply.domain.term.Term
import apply.domain.term.TermRepository
import apply.domain.member.Gender
import apply.domain.member.Password
import apply.domain.member.Member
import apply.domain.member.MemberRepository
import org.springframework.boot.CommandLineRunner
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
import support.createLocalDate
import support.createLocalDateTime
import support.flattenByMargin

@Profile("local")
@Transactional
@Component
class DatabaseInitializer(
private val administratorRepository: AdministratorRepository,
private val agreementRepository: AgreementRepository,
private val termRepository: TermRepository,
private val recruitmentRepository: RecruitmentRepository,
private val recruitmentItemRepository: RecruitmentItemRepository,
@@ -58,7 +62,7 @@ class DatabaseInitializer(
private val judgmentItemRepository: JudgmentItemRepository,
private val assignmentRepository: AssignmentRepository,
private val mailHistoryRepository: MailHistoryRepository,
private val database: Database
private val database: Database,
) : CommandLineRunner {
override fun run(vararg args: String) {
if (shouldSkip()) return
@@ -76,6 +80,7 @@ class DatabaseInitializer(

private fun populate() {
populateAdministrator()
populateAgreement()
populateTerms()
populateRecruitments()
populateRecruitmentItems()
@@ -100,6 +105,18 @@ class DatabaseInitializer(
administratorRepository.save(administrator)
}

private fun populateAgreement() {
val agreement = Agreement(
20240418,
"""
|<p>(주)우아한형제들은 아래와 같이 지원자의 개인정보를 수집 및 이용합니다.</p>
|<br>
|<p><strong>보유 및 이용기간</strong> : <strong><span style="font-size:1.2rem">탈퇴 시 또는 이용목적 달성 시 파기</span></strong>(단, 관련법령 및 회사정책에 의해 보관이 필요한 경우 해당기간 동안 보관)</p>
""".flattenByMargin()
)
agreementRepository.save(agreement)
}

private fun populateTerms() {
val terms = listOf(
Term("1기"),
25 changes: 25 additions & 0 deletions src/main/kotlin/apply/domain/agreement/Agreement.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package apply.domain.agreement

import support.domain.BaseRootEntity
import java.time.format.DateTimeFormatter
import javax.persistence.Column
import javax.persistence.Entity

@Entity
class Agreement(
@Column(nullable = false)
val version: Int,

@Column(nullable = false, length = 5000)
val content: String,
id: Long = 0L,
) : BaseRootEntity<Agreement>(id) {
init {
runCatching { ISO_BASIC.parse(version.toString()) }
.also { require(it.isSuccess) { "버전 형식이 일치하지 않습니다." } }
}

companion object {
private val ISO_BASIC: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd")
}
}
10 changes: 10 additions & 0 deletions src/main/kotlin/apply/domain/agreement/AgreementRepository.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package apply.domain.agreement

import org.springframework.data.jpa.repository.JpaRepository

fun AgreementRepository.getFirstByOrderByVersionDesc(): Agreement = findFirstByOrderByVersionDesc()
?: throw NoSuchElementException("동의서가 존재하지 않습니다.")

interface AgreementRepository : JpaRepository<Agreement, Long> {
fun findFirstByOrderByVersionDesc(): Agreement?
}
19 changes: 19 additions & 0 deletions src/main/kotlin/apply/ui/api/AgreementRestController.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package apply.ui.api

import apply.application.AgreementResponse
import apply.application.AgreementService
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RequestMapping("/api/agreements")
@RestController
class AgreementRestController(
private val agreementService: AgreementService,
) {
@GetMapping("/latest")
fun latest(): ResponseEntity<ApiResponse<AgreementResponse>> {
return ResponseEntity.ok(ApiResponse.success(agreementService.latest()))
}
}
6 changes: 6 additions & 0 deletions src/main/kotlin/support/Strings.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package support

/**
* Inside [trimMargin], "\n" is used so there is no need to distinguish between operating systems.
*/
fun String.flattenByMargin(marginPrefix: String = "|"): String = trimMargin(marginPrefix).replace("\n", "")
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
create table agreement
(
id bigint not null auto_increment,
content varchar(5000) not null,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a: 만약 5000 넘어갈 일이 있다면 있을 것 같으면 미리 TEXT로 만들어둬도 좋겠네요

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#738 (comment) 에서 실제로 사용되는 동의서는 636자, 1,180바이트이네요. 1,000자를 넘을 가능성은 거의 없어 보이지만, 레코드가 약 8,000바이트 정도를 넘어야 길이가 긴 컬럼을 off-page에 저장한다고 하니 지금은 걱정하지 않아도 될 것 같아요.

version integer not null,
primary key (id)
) engine = InnoDB
default charset = utf8mb4;
28 changes: 28 additions & 0 deletions src/test/kotlin/apply/AgreementFixtures.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package apply

import apply.application.AgreementResponse
import apply.domain.agreement.Agreement
import support.flattenByMargin

private val AGREEMENT_CONTENT: String =
"""
|<p>(주)우아한형제들은 아래와 같이 지원자의 개인정보를 수집 및 이용합니다.</p>
|<br>
|<p><strong>보유 및 이용기간</strong> : <strong><span style="font-size:1.2rem">탈퇴 시 또는 이용목적 달성 시 파기</span></strong>(단, 관련법령 및 회사정책에 의해 보관이 필요한 경우 해당기간 동안 보관)</p>
""".flattenByMargin()

fun createAgreement(
version: Int = 20240416,
content: String = AGREEMENT_CONTENT,
id: Long = 0L,
): Agreement {
return Agreement(version, content, id)
}

fun createAgreementResponse(
version: Int = 20240416,
content: String = AGREEMENT_CONTENT,
id: Long = 0L,
): AgreementResponse {
return AgreementResponse(id, version, content)
}
38 changes: 38 additions & 0 deletions src/test/kotlin/apply/domain/agreement/AgreementRepositoryTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package apply.domain.agreement

import apply.createAgreement
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.ExpectSpec
import io.kotest.extensions.spring.SpringTestExtension
import io.kotest.extensions.spring.SpringTestLifecycleMode
import io.kotest.matchers.shouldBe
import support.test.RepositoryTest

@RepositoryTest
class AgreementRepositoryTest(
private val agreementRepository: AgreementRepository,
) : ExpectSpec({
extensions(SpringTestExtension(SpringTestLifecycleMode.Root))

context("동의서 조회") {
agreementRepository.saveAll(
listOf(
createAgreement(version = 20240416),
createAgreement(version = 20240506),
)
)

expect("가장 최신 버전의 동의서를 조회한다") {
val actual = agreementRepository.getFirstByOrderByVersionDesc()
actual.version shouldBe 20240506
}
}

context("동의서 조회 예외") {
expect("동의서가 없으면 예외가 발생한다") {
shouldThrow<NoSuchElementException> {
agreementRepository.getFirstByOrderByVersionDesc()
}
}
}
})
13 changes: 13 additions & 0 deletions src/test/kotlin/apply/domain/agreement/AgreementTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package apply.domain.agreement

import apply.createAgreement
import io.kotest.assertions.throwables.shouldNotThrowAny
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec

class AgreementTest : StringSpec({
"동의서 버전은 yyyyMMdd 형식으로 관리한다" {
shouldNotThrowAny { createAgreement(version = 20240416) }
shouldThrow<IllegalArgumentException> { createAgreement(version = 1) }
}
})
Loading