Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
34 changes: 29 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,36 @@
<p align="center">
<img src="https://github.com/user-attachments/assets/1060eb65-877a-4fb4-bbdc-9ee68a66afa8" alt="토덕 To.duck Logo" width="150"/>
</p>
<h1 align="center">토덕 To.duck</h1>
<p align="center">
<strong>성인 ADHD인을 위한 토닥임</strong>
</p>
<p align="center">
<a href="https://apps.apple.com/us/app/%ED%86%A0%EB%8D%95-to-duck-%EC%84%B1%EC%9D%B8-adhd%EC%9D%B8%EC%9D%84-%EC%9C%84%ED%95%9C-%ED%86%A0%EB%8B%A5%EC%9E%84/id6502951629">
<img src="https://tools.applemediaservices.com/api/badges/download-on-the-app-store/black/en-us?size=250x83" alt="Download on the App Store" height="120"/>
</a>
</p>
<p align="center">
Copyright © 2025 To.duck Team
</p>
<br/>

### 🔗 API
- [https://api-toduck.seol.pro](https://api-toduck.seol.pro/)
- [Swagger UI](https://api-toduck.seol.pro/swagger-ui/index.html)
- [에러 코드](https://api-toduck.seol.pro/exception-codes)

<br/>

### 🧑‍💻 Authors
| 강민기 | 박준하 | 설진영 | 최연우 |
|:----------------------------------------------------------------------------:|:----------------------------------------------------------------------------:|:----------------------------------------------------------------------------:|:----------------------------------------------------------------------------:|
| <img src="https://avatars.githubusercontent.com/u/75325326?v=4" width="80"/> | <img src="https://avatars.githubusercontent.com/u/67590577?v=4" width="80"/> | <img src="https://avatars.githubusercontent.com/u/70826982?v=4" width="80"/> | <img src="https://avatars.githubusercontent.com/u/50083524?v=4" width="80"/> |
| [@kang20](https://github.com/kang20) | [@Junad-Park](https://github.com/Junad-Park) | [@Seol-JY](https://github.com/Seol-JY) | [@wafla](https://github.com/wafla) |

| 강민기 | 박준하 | 설진영 |
|:----------------------------------------------------------------------------:|:----------------------------------------------------------------------------:|:----------------------------------------------------------------------------:|
| <img src="https://avatars.githubusercontent.com/u/75325326?v=4" width="80"/> | <img src="https://avatars.githubusercontent.com/u/67590577?v=4" width="80"/> | <img src="https://avatars.githubusercontent.com/u/70826982?v=4" width="80"/> |
| [@kang20](https://github.com/kang20) | [@Junad-Park](https://github.com/Junad-Park) | [@Seol-JY](https://github.com/Seol-JY) |
<br/>

### 📜 Docs

- [초기 개발환경 설정](https://kyxxn.notion.site/fdf5a3a523f040328a9c68d4377ff997?pvs=74)
- [개발 가이드](https://kyxxn.notion.site/9a2670768f784ea5bb3ca8f044ede895)
- [API 개요](https://kyxxn.notion.site/API-e775e161efa6459583a0ee0d586c4d19)
51 changes: 51 additions & 0 deletions sql/init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,57 @@ CREATE TABLE events_social
FOREIGN KEY (user_id) REFERENCES users (id)
);

CREATE TABLE admin
(
id BIGINT PRIMARY KEY auto_increment,
user_id BIGINT NOT NULL UNIQUE,
display_name VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
deleted_at DATETIME NULL,
FOREIGN KEY (user_id) REFERENCES users (id)
);

CREATE TABLE inquiry
(
id BIGINT PRIMARY KEY auto_increment,
user_id BIGINT NOT NULL,
type VARCHAR(50) NOT NULL,
content VARCHAR(1024) NOT NULL,
status VARCHAR(50) NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
deleted_at DATETIME NULL,
FOREIGN KEY (user_id) REFERENCES users (id)
);

CREATE TABLE inquiry_image_file
(
id BIGINT PRIMARY KEY auto_increment,
inquiry_id BIGINT NOT NULL,
url VARCHAR(1024) NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
deleted_at DATETIME NULL,
FOREIGN KEY (inquiry_id) REFERENCES inquiry (id)
);

CREATE TABLE inquiry_answer
(
id BIGINT PRIMARY KEY auto_increment,
admin_id BIGINT NOT NULL,
inquiry_id BIGINT NOT NULL,
content VARCHAR(1024) NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
deleted_at DATETIME NULL,

CONSTRAINT uq_inquiry_answer UNIQUE (inquiry_id),

FOREIGN KEY (admin_id) REFERENCES admin (id),
FOREIGN KEY (inquiry_id) REFERENCES inquiry (id)
);

CREATE TABLE account_deletion_log
(
id BIGINT PRIMARY KEY auto_increment,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package im.toduck.domain.admin.common.mapper;

import java.util.List;

import im.toduck.domain.admin.persistence.entity.Admin;
import im.toduck.domain.admin.presentation.dto.response.AdminListResponse;
import im.toduck.domain.admin.presentation.dto.response.AdminResponse;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class AdminMapper {
public static AdminResponse toAdminResponse(final Admin admin) {
return new AdminResponse(
admin.getId(),
admin.getUser().getId(),
admin.getDisplayName()
);
}

public static AdminListResponse toAdminListResponse(final List<AdminResponse> admins) {
return AdminListResponse.toListAdminResponse(admins);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package im.toduck.domain.admin.domain.service;

import java.util.List;
import java.util.Optional;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import im.toduck.domain.admin.common.mapper.AdminMapper;
import im.toduck.domain.admin.persistence.entity.Admin;
import im.toduck.domain.admin.persistence.repository.AdminRepository;
import im.toduck.domain.admin.presentation.dto.request.AdminCreateRequest;
import im.toduck.domain.admin.presentation.dto.request.AdminUpdateRequest;
import im.toduck.domain.admin.presentation.dto.response.AdminResponse;
import im.toduck.domain.user.persistence.entity.User;
import im.toduck.domain.user.persistence.entity.UserRole;
import im.toduck.domain.user.persistence.repository.UserRepository;
import im.toduck.global.exception.CommonException;
import im.toduck.global.exception.ExceptionCode;
import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class AdminService {
private final UserRepository userRepository;
private final AdminRepository adminRepository;

@Transactional
public Admin getAdmin(final Long userId) {
return adminRepository.findActiveAdminByUserId(userId)
.orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_ADMIN));
}

// Role은 Admin이지만 admin 테이블에 등록되어있지 않은 경우 사용합니다.
@Transactional
public Admin getAdminBySameUser(final Long userId) {
return adminRepository.findActiveAdminByUserId(userId)
.orElseGet(() -> createDefaultAdmin(userId));
}

private Admin createDefaultAdmin(final Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER));

if (user.getRole() != UserRole.ADMIN) {
throw CommonException.from(ExceptionCode.NOT_FOUND_ADMIN);
}

Admin admin = Admin.builder()
.user(user)
.displayName("토덕 관리자")
.build();

return adminRepository.save(admin);
Comment thread
wafla marked this conversation as resolved.
}

@Transactional(readOnly = true)
public List<AdminResponse> getAdmins() {
List<Admin> admins = adminRepository.findAllActiveAdmins();
return admins.stream()
.map(AdminMapper::toAdminResponse)
.toList();
}

@Transactional(readOnly = true)
public Optional<Admin> getExistingAdmin(final Long userId) {
return adminRepository.findByUserIdIncludeDeleted(userId);
}

@Transactional
public Admin createAdmin(final AdminCreateRequest request, final User user) {
Admin admin = Admin.builder()
.user(user)
.displayName(request.displayName())
.build();

return adminRepository.save(admin);
}

@Transactional
public void updateAdmin(final Long userId, final AdminUpdateRequest request) {
Admin admin = adminRepository.findActiveAdminByUserId(userId)
.orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_ADMIN));
if (request.displayName() != null) {
admin.updateDisplayName(request.displayName());
}
}
Comment thread
wafla marked this conversation as resolved.

@Transactional
public void deleteAdmin(final Admin admin) {
adminRepository.delete(admin);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package im.toduck.domain.admin.domain.usecase;

import java.util.List;

import org.springframework.transaction.annotation.Transactional;

import im.toduck.domain.admin.common.mapper.AdminMapper;
import im.toduck.domain.admin.domain.service.AdminService;
import im.toduck.domain.admin.persistence.entity.Admin;
import im.toduck.domain.admin.presentation.dto.request.AdminCreateRequest;
import im.toduck.domain.admin.presentation.dto.request.AdminUpdateRequest;
import im.toduck.domain.admin.presentation.dto.response.AdminListResponse;
import im.toduck.domain.admin.presentation.dto.response.AdminResponse;
import im.toduck.domain.user.domain.service.UserService;
import im.toduck.domain.user.persistence.entity.User;
import im.toduck.domain.user.persistence.entity.UserRole;
import im.toduck.global.annotation.UseCase;
import im.toduck.global.exception.CommonException;
import im.toduck.global.exception.ExceptionCode;
import lombok.RequiredArgsConstructor;

@UseCase
@RequiredArgsConstructor
public class AdminUseCase {
private final AdminService adminService;
private final UserService userService;

@Transactional
public AdminResponse getAdmin(final Long userId) {
Admin admin = adminService.getAdmin(userId);

return AdminMapper.toAdminResponse(admin);
}

@Transactional
public AdminListResponse getAdmins() {
List<AdminResponse> admins = adminService.getAdmins();

return AdminMapper.toAdminListResponse(admins);
}

@Transactional
public Admin createAdmin(final AdminCreateRequest request) {
User user = userService.getUserById(request.userId())
.orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER));

Admin existingAdmin = adminService.getExistingAdmin(user.getId()).orElse(null);

// 이미 활성화된 관리자인 경우
if (existingAdmin != null && existingAdmin.getDeletedAt() == null) {
throw CommonException.from(ExceptionCode.DUPLICATE_ADMIN);
}

// 삭제된 관리자 복구
if (existingAdmin != null) {
existingAdmin.revive();
existingAdmin.updateDisplayName(request.displayName());

if (user.getRole() == UserRole.USER) {
user.promoteToAdmin();
}

return existingAdmin;
}

// 신규 관리자
Admin admin = adminService.createAdmin(request, user);

if (user.getRole() == UserRole.USER) {
user.promoteToAdmin();
}

return admin;
}

@Transactional
public void updateAdmin(final Long userId, final AdminUpdateRequest request) {
User user = userService.getUserById(userId)
.orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER));

adminService.updateAdmin(userId, request);
}
Comment thread
wafla marked this conversation as resolved.

@Transactional
public void deleteAdmin(final Long userId) {
Admin admin = adminService.getAdmin(userId);

User user = admin.getUser();
user.demoteToUser();

adminService.deleteAdmin(admin);
}
Comment thread
wafla marked this conversation as resolved.
}
51 changes: 51 additions & 0 deletions src/main/java/im/toduck/domain/admin/persistence/entity/Admin.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package im.toduck.domain.admin.persistence.entity;

import org.hibernate.annotations.SQLDelete;

import im.toduck.domain.user.persistence.entity.User;
import im.toduck.global.base.entity.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "admin")
@Getter
@NoArgsConstructor
@SQLDelete(sql = "UPDATE admin SET deleted_at = NOW() where id=?")
public class Admin extends BaseEntity {
Comment thread
wafla marked this conversation as resolved.
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false, unique = true)
private User user;

@Column(length = 255, nullable = false)
private String displayName;

@Builder
private Admin(User user,
String displayName) {
this.user = user;
this.displayName = displayName;
}

public void updateDisplayName(final String displayName) {
this.displayName = displayName;
}

public void revive() {
this.deletedAt = null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package im.toduck.domain.admin.persistence.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import im.toduck.domain.admin.persistence.entity.Admin;
import im.toduck.domain.admin.persistence.repository.querydsl.AdminRepositoryCustom;

@Repository
public interface AdminRepository extends JpaRepository<Admin, Long>, AdminRepositoryCustom {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package im.toduck.domain.admin.persistence.repository.querydsl;

import java.util.List;
import java.util.Optional;

import im.toduck.domain.admin.persistence.entity.Admin;

public interface AdminRepositoryCustom {
Optional<Admin> findActiveAdminByUserId(Long userId);

List<Admin> findAllActiveAdmins();

Optional<Admin> findByUserIdIncludeDeleted(Long userId);
}
Loading