[#39] '시간' -> '일정' 스키마 변경으로 테마별 시간 지정 (#40)

<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #39

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- 기존 시간 테이블은 유지하고 일정 스키마 및 기능 도입
- 관련 프론트엔드 페이지 생성

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 이전 테마 작업과 마찬가지로 모든 API를 대상으로 통합 테스트 진행

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #40
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
This commit is contained in:
이상진 2025-09-04 04:14:12 +00:00 committed by 이상진
parent bdc99c7883
commit 258b5f042d
40 changed files with 2230 additions and 218 deletions

View File

@ -11,6 +11,7 @@
"axios": "^1.7.2", "axios": "^1.7.2",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"flatpickr": "^4.6.13", "flatpickr": "^4.6.13",
"json-bigint": "^1.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-flatpickr": "^3.10.13", "react-flatpickr": "^3.10.13",
@ -18,6 +19,7 @@
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
}, },
"devDependencies": { "devDependencies": {
"@types/json-bigint": "^1.0.4",
"@types/react": "^18.2.66", "@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22", "@types/react-dom": "^18.2.22",
"@types/react-flatpickr": "^3.8.11", "@types/react-flatpickr": "^3.8.11",
@ -1323,6 +1325,13 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/json-bigint": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@types/json-bigint/-/json-bigint-1.0.4.tgz",
"integrity": "sha512-ydHooXLbOmxBbubnA7Eh+RpBzuaIiQjh8WGJYQB50JFGFrdxW7JzVlyEV7fAXw0T2sqJ1ysTneJbiyNLqZRAag==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@ -1690,6 +1699,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/bootstrap": { "node_modules/bootstrap": {
"version": "5.3.7", "version": "5.3.7",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz",
@ -2833,6 +2851,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.0.0"
}
},
"node_modules/json-buffer": { "node_modules/json-buffer": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",

View File

@ -13,6 +13,7 @@
"axios": "^1.7.2", "axios": "^1.7.2",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"flatpickr": "^4.6.13", "flatpickr": "^4.6.13",
"json-bigint": "^1.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-flatpickr": "^3.10.13", "react-flatpickr": "^3.10.13",
@ -20,6 +21,7 @@
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
}, },
"devDependencies": { "devDependencies": {
"@types/json-bigint": "^1.0.4",
"@types/react": "^18.2.66", "@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22", "@types/react-dom": "^18.2.22",
"@types/react-flatpickr": "^3.8.11", "@types/react-flatpickr": "^3.8.11",

View File

@ -24,6 +24,7 @@ import HomePageV2 from './pages/v2/HomePageV2';
import LoginPageV2 from './pages/v2/LoginPageV2'; import LoginPageV2 from './pages/v2/LoginPageV2';
import SignupPageV2 from './pages/v2/SignupPageV2'; import SignupPageV2 from './pages/v2/SignupPageV2';
import AdminThemeEditPage from './pages/admin/AdminThemeEditPage'; import AdminThemeEditPage from './pages/admin/AdminThemeEditPage';
import AdminSchedulePage from './pages/admin/AdminSchedulePage';
const AdminRoutes = () => ( const AdminRoutes = () => (
<AdminLayout> <AdminLayout>
@ -34,51 +35,52 @@ const AdminRoutes = () => (
<Route path="/theme" element={<AdminThemePage />} /> <Route path="/theme" element={<AdminThemePage />} />
<Route path="/theme/edit/:themeId" element={<AdminThemeEditPage />} /> <Route path="/theme/edit/:themeId" element={<AdminThemeEditPage />} />
<Route path="/waiting" element={<AdminWaitingPage />} /> <Route path="/waiting" element={<AdminWaitingPage />} />
<Route path="/schedule" element={<AdminSchedulePage />} />
</Routes> </Routes>
</AdminLayout> </AdminLayout>
); );
function App() { function App() {
return ( return (
<AuthProvider> <AuthProvider>
<Router> <Router>
<Routes> <Routes>
<Route path="/admin/*" element={ <Route path="/admin/*" element={
<AdminRoute> <AdminRoute>
<AdminRoutes /> <AdminRoutes />
</AdminRoute> </AdminRoute>
} /> } />
<Route path="/*" element={ <Route path="/*" element={
<Layout> <Layout>
<Routes> <Routes>
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} /> <Route path="/signup" element={<SignupPage />} />
<Route path="/reservation" element={<ReservationPage />} /> <Route path="/reservation" element={<ReservationPage />} />
<Route path="/my-reservation" element={<MyReservationPage />} /> <Route path="/my-reservation" element={<MyReservationPage />} />
<Route path="/my-reservation/v2" element={<MyReservationPageV2 />} /> <Route path="/my-reservation/v2" element={<MyReservationPageV2 />} />
{/* V2 Pages */} {/* V2 Pages */}
<Route path="/v2/home" element={<HomePageV2 />} /> <Route path="/v2/home" element={<HomePageV2 />} />
<Route path="/v2/login" element={<LoginPageV2 />} /> <Route path="/v2/login" element={<LoginPageV2 />} />
<Route path="/v2/signup" element={<SignupPageV2 />} /> <Route path="/v2/signup" element={<SignupPageV2 />} />
{/* V2 Reservation Flow */} {/* V2 Reservation Flow */}
<Route path="/v2/reservation" element={<ReservationStep1Page />} /> <Route path="/v2/reservation" element={<ReservationStep1Page />} />
<Route path="/v2/reservation/payment" element={<ReservationStep2Page />} /> <Route path="/v2/reservation/payment" element={<ReservationStep2Page />} />
<Route path="/v2/reservation/success" element={<ReservationSuccessPage />} /> <Route path="/v2/reservation/success" element={<ReservationSuccessPage />} />
{/* V2.1 Reservation Flow */} {/* V2.1 Reservation Flow */}
<Route path="/v2-1/reservation" element={<ReservationStep1PageV21 />} /> <Route path="/v2-1/reservation" element={<ReservationStep1PageV21 />} />
<Route path="/v2-1/reservation/payment" element={<ReservationStep2PageV21 />} /> <Route path="/v2-1/reservation/payment" element={<ReservationStep2PageV21 />} />
<Route path="/v2-1/reservation/success" element={<ReservationSuccessPageV21 />} /> <Route path="/v2-1/reservation/success" element={<ReservationSuccessPageV21 />} />
</Routes> </Routes>
</Layout> </Layout>
} /> } />
</Routes> </Routes>
</Router> </Router>
</AuthProvider> </AuthProvider>
); );
} }
export default App; export default App;

View File

@ -1,8 +1,28 @@
import axios, { type AxiosError, type AxiosRequestConfig, type Method } from 'axios'; import axios, { type AxiosError, type AxiosRequestConfig, type Method } from 'axios';
import JSONbig from 'json-bigint';
// Create a JSONbig instance that stores big integers as strings
const JSONbigString = JSONbig({ storeAsString: true });
const apiClient = axios.create({ const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api', baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 10000, timeout: 10000,
// transformResponse is used to parse JSON with big integers (Long type from backend) as strings.
// This prevents precision loss in JavaScript.
transformResponse: [(data) => {
// Do not transform if data is not a string or is empty
if (!data || typeof data !== 'string') {
return data;
}
try {
// Use the configured JSONbig instance to parse the data
return JSONbigString.parse(data);
} catch (e) {
// If parsing fails, it might not be JSON, so return original data
// This is the default behavior of axios if parsing fails
return data;
}
}],
}); });
export const isLoginRequiredError = (error: any): boolean => { export const isLoginRequiredError = (error: any): boolean => {

View File

@ -0,0 +1,32 @@
import apiClient from '../apiClient';
import type {
AvailableThemeIdListResponse,
ScheduleCreateRequest,
ScheduleCreateResponse, ScheduleDetailRetrieveResponse,
ScheduleRetrieveListResponse,
ScheduleUpdateRequest
} from './scheduleTypes';
export const findAvailableThemesByDate = async (date: string): Promise<AvailableThemeIdListResponse> => {
return await apiClient.get<AvailableThemeIdListResponse>(`/schedules/themes?date=${date}`);
};
export const findSchedules = async (date: string, themeId: string): Promise<ScheduleRetrieveListResponse> => {
return await apiClient.get<ScheduleRetrieveListResponse>(`/schedules?date=${date}&themeId=${themeId}`);
};
export const findScheduleById = async (id: string): Promise<ScheduleDetailRetrieveResponse> => {
return await apiClient.get<ScheduleDetailRetrieveResponse>(`/schedules/${id}`);
}
export const createSchedule = async (request: ScheduleCreateRequest): Promise<ScheduleCreateResponse> => {
return await apiClient.post<ScheduleCreateResponse>('/schedules', request);
};
export const updateSchedule = async (id: string, request: ScheduleUpdateRequest): Promise<void> => {
await apiClient.patch(`/schedules/${id}`, request);
};
export const deleteSchedule = async (id: string): Promise<void> => {
await apiClient.del(`/schedules/${id}`);
};

View File

@ -0,0 +1,48 @@
export enum ScheduleStatus {
AVAILABLE = 'AVAILABLE',
PENDING = 'PENDING',
RESERVED = 'RESERVED',
BLOCKED = 'BLOCKED',
}
export interface AvailableThemeIdListResponse {
themeIds: string[];
}
export interface ScheduleRetrieveResponse {
id: string;
time: string; // "HH:mm"
status: ScheduleStatus;
}
export interface ScheduleRetrieveListResponse {
schedules: ScheduleRetrieveResponse[];
}
export interface ScheduleCreateRequest {
date: string; // "yyyy-MM-dd"
time: string; // "HH:mm"
themeId: string;
}
export interface ScheduleCreateResponse {
id: string;
}
export interface ScheduleUpdateRequest {
date?: string; // "yyyy-MM-dd"
time?: string; // "HH:mm"
themeId?: string;
status?: ScheduleStatus;
}
export interface ScheduleDetailRetrieveResponse {
id: string;
date: string; // "yyyy-MM-dd"
time: string; // "HH:mm"
status: ScheduleStatus;
createdAt: string; // or Date
createdBy: string;
updatedAt: string; // or Date
updatedBy: string;
}

View File

@ -4,7 +4,8 @@ import type {
AdminThemeSummaryRetrieveListResponse, AdminThemeSummaryRetrieveListResponse,
ThemeCreateRequest, ThemeCreateRequest,
ThemeCreateRequestV2, ThemeCreateResponse, ThemeCreateRequestV2, ThemeCreateResponse,
ThemeCreateResponseV2, ThemeRetrieveListResponse, ThemeCreateResponseV2, ThemeListRetrieveRequest, ThemeRetrieveListResponse,
ThemeRetrieveListResponseV2,
ThemeUpdateRequest, ThemeUpdateRequest,
UserThemeRetrieveListResponse UserThemeRetrieveListResponse
} from './themeTypes'; } from './themeTypes';
@ -48,3 +49,7 @@ export const deleteTheme = async (id: string): Promise<void> => {
export const fetchUserThemes = async (): Promise<UserThemeRetrieveListResponse> => { export const fetchUserThemes = async (): Promise<UserThemeRetrieveListResponse> => {
return await apiClient.get<UserThemeRetrieveListResponse>('/v2/themes'); return await apiClient.get<UserThemeRetrieveListResponse>('/v2/themes');
}; };
export const findThemesByIds = async (request: ThemeListRetrieveRequest): Promise<ThemeRetrieveListResponseV2> => {
return await apiClient.post<ThemeRetrieveListResponseV2>('/themes/retrieve', request);
};

View File

@ -123,6 +123,28 @@ export interface UserThemeRetrieveListResponse {
themes: UserThemeRetrieveResponse[]; themes: UserThemeRetrieveResponse[];
} }
export interface ThemeListRetrieveRequest {
themeIds: string[];
}
export interface ThemeRetrieveResponseV2 {
id: string;
name: string;
thumbnailUrl: string;
description: string;
difficulty: Difficulty;
price: number;
minParticipants: number;
maxParticipants: number;
availableMinutes: number;
expectedMinutesFrom: number;
expectedMinutesTo: number;
}
export interface ThemeRetrieveListResponseV2 {
themes: ThemeRetrieveResponseV2[];
}
// @ts-ignore // @ts-ignore
export enum Difficulty { export enum Difficulty {
VERY_EASY = 'VERY_EASY', VERY_EASY = 'VERY_EASY',

View File

@ -0,0 +1,214 @@
.admin-schedule-container {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.page-title {
font-size: 2rem;
font-weight: bold;
margin-bottom: 2rem;
text-align: center;
}
.schedule-controls {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
padding: 1.5rem;
background-color: #f9f9f9;
border-radius: 8px;
align-items: center;
}
.schedule-controls .form-group {
display: flex;
flex-direction: column;
}
.schedule-controls .form-label {
font-size: 0.9rem;
margin-bottom: 0.5rem;
color: #555;
}
.schedule-controls .form-input,
.schedule-controls .form-select {
padding: 0.75rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
}
.section-card {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 1.5rem;
}
.table-header {
display: flex;
justify-content: flex-end;
margin-bottom: 1rem;
}
.table-container {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid #eee;
vertical-align: middle;
}
th {
font-weight: 600;
background-color: #f7f7f7;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: background-color 0.2s;
white-space: nowrap;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover {
background-color: #0056b3;
}
.btn-danger {
background-color: #dc3545;
color: white;
}
.btn-danger:hover {
background-color: #c82333;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #5a6268;
}
.editing-row td {
padding-top: 1rem;
padding-bottom: 1rem;
}
.editing-row .form-input {
width: 100%;
padding: 0.5rem;
}
.action-buttons {
display: flex;
gap: 0.5rem;
}
/* Styles for schedule details row */
.schedule-details-row td {
padding: 0;
background-color: #f8f9fa;
}
.details-form-container {
padding: 1.5rem;
}
.details-form-container .form-card {
background-color: #fff;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
margin-bottom: 1.5rem;
}
.details-form-container .form-section {
margin-bottom: 1.5rem;
}
.details-form-container .form-row {
display: flex;
gap: 1.5rem;
margin-bottom: 1rem;
}
.details-form-container .form-group {
flex: 1;
display: flex;
flex-direction: column;
}
.details-form-container .form-label {
font-size: 0.9rem;
font-weight: 600;
color: #495057;
margin-bottom: 0.5rem;
}
.details-form-container .form-input,
.details-form-container .form-select {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 1px solid #dee2e6;
border-radius: 4px;
height: 3rem;
box-sizing: border-box; /* Ensures padding/border are included in height */
}
.details-form-container .button-group {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}
.audit-info {
padding: 1.5rem;
border: 1px solid #dee2e6;
border-radius: 8px;
background-color: #fff;
margin-bottom: 1.5rem; /* Add margin to separate from buttons */
}
.audit-title {
font-size: 1.1rem;
font-weight: 600;
color: #343a40;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #dee2e6;
}
.audit-body p {
margin: 0.5rem 0;
font-size: 0.9rem;
color: #495057;
}
.audit-body p strong {
color: #212529;
margin-right: 0.5rem;
}

View File

@ -40,45 +40,116 @@
color: #191F28; color: #191F28;
} }
/* Date Selector */ /* Date Carousel */
.date-selector { .date-carousel {
display: flex; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
margin: 20px 0;
}
.date-options-container {
display: flex;
gap: 8px;
overflow-x: hidden;
flex-grow: 1;
justify-content: space-between;
margin: 0px 15px;
}
.carousel-arrow, .today-button {
background-color: #F2F4F6;
border: 1px solid #E5E8EB;
border-radius: 50%;
width: 36px;
height: 36px;
font-size: 20px;
font-weight: bold;
color: #4E5968;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background-color 0.2s;
}
.today-button {
border-radius: 8px;
font-size: 14px;
font-weight: 600;
width: auto;
padding: 0 15px;
}
.carousel-arrow:hover, .today-button:hover {
background-color: #E5E8EB;
} }
.date-option { .date-option {
cursor: pointer; cursor: pointer;
padding: 12px 16px; padding: 8px;
border-radius: 8px; border-radius: 8px;
text-align: center; display: flex;
border: 2px solid transparent; flex-direction: column;
background-color: #F2F4F6; align-items: center;
transition: all 0.2s ease-in-out; justify-content: center;
flex-grow: 1; border: 1px solid transparent;
transition: all 0.3s ease;
width: 60px;
flex-shrink: 0;
} }
.date-option:hover { .date-option:hover {
background-color: #E5E8EB; background-color: #f0f0f0;
} }
.date-option.active { .date-option.active {
background-color: #3182F6; border: 1px solid #007bff;
color: #ffffff; background-color: #e7f3ff;
border-color: #3182F6;
font-weight: 600;
} }
.date-option .day-of-week { .date-option .day-of-week {
font-size: 14px; font-size: 12px;
margin-bottom: 4px; color: #888;
} }
.date-option .day { .date-option.active .day-of-week {
font-size: 18px; color: #007bff;
font-weight: 700;
} }
.date-option .day-circle {
font-size: 16px;
font-weight: bold;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-top: 4px;
background-color: #f0f0f0;
color: #333;
}
.date-option.active .day-circle {
background-color: #007bff;
color: white;
}
.date-option.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.date-option.disabled .day-circle {
background-color: #E5E8EB;
color: #B0B8C1;
}
/* Theme List */ /* Theme List */
.theme-list { .theme-list {
display: grid; display: grid;

View File

@ -60,3 +60,20 @@
.cursor-pointer { .cursor-pointer {
cursor: pointer; cursor: pointer;
} }
/* =================================== */
/* Button Group */
/* =================================== */
.button-group {
display: flex;
justify-content: flex-end; /* Aligns buttons to the right by default */
gap: 0.75rem; /* 12px */
}
.button-group.full-width {
justify-content: stretch;
}
.button-group.full-width .btn {
flex-grow: 1;
}

View File

@ -81,11 +81,15 @@ a {
margin-top: 72px; margin-top: 72px;
} }
.form-select {
margin: 10px 0px !important;
}
.button-group { .button-group {
margin-top: 32px; margin-top: 32px;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
justify-content: center; justify-content: center !important;
gap: 16px; gap: 16px;
} }

View File

@ -35,7 +35,7 @@ const LoginPage: React.FC = () => {
<div className="form-group"> <div className="form-group">
<input type="password" className="form-control" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} /> <input type="password" className="form-control" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} />
</div> </div>
<div className="d-flex justify-content-between"> <div className="button-group full-width">
<button className="btn btn-outline-custom" onClick={() => navigate('/signup')}>Sign Up</button> <button className="btn btn-outline-custom" onClick={() => navigate('/signup')}>Sign Up</button>
<button className="btn btn-custom" onClick={handleLogin}>Login</button> <button className="btn btn-custom" onClick={handleLogin}>Login</button>
</div> </div>

View File

@ -25,6 +25,7 @@ const AdminNavbar: React.FC = () => {
<Link className="nav-link" to="/admin/waiting"></Link> <Link className="nav-link" to="/admin/waiting"></Link>
<Link className="nav-link" to="/admin/theme"></Link> <Link className="nav-link" to="/admin/theme"></Link>
<Link className="nav-link" to="/admin/time"></Link> <Link className="nav-link" to="/admin/time"></Link>
<Link className="nav-link" to="/admin/schedule"></Link>
</div> </div>
<div className="nav-actions"> <div className="nav-actions">
{!loggedIn ? ( {!loggedIn ? (

View File

@ -0,0 +1,315 @@
import { isLoginRequiredError } from '@_api/apiClient';
import { createSchedule, deleteSchedule, findScheduleById, findSchedules, updateSchedule } from '@_api/schedule/scheduleAPI';
import { ScheduleStatus, type ScheduleDetailRetrieveResponse, type ScheduleRetrieveResponse } from '@_api/schedule/scheduleTypes';
import { fetchAdminThemes } from '@_api/theme/themeAPI';
import type { AdminThemeSummaryRetrieveResponse } from '@_api/theme/themeTypes';
import '@_css/admin-schedule-page.css';
import React, { Fragment, useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
const getScheduleStatusText = (status: ScheduleStatus): string => {
switch (status) {
case ScheduleStatus.AVAILABLE:
return '예약 가능';
case ScheduleStatus.PENDING:
return '예약 진행 중';
case ScheduleStatus.RESERVED:
return '예약 완료';
case ScheduleStatus.BLOCKED:
return '예약 불가';
default:
return status;
}
};
const AdminSchedulePage: React.FC = () => {
const [schedules, setSchedules] = useState<ScheduleRetrieveResponse[]>([]);
const [themes, setThemes] = useState<AdminThemeSummaryRetrieveResponse[]>([]);
const [selectedThemeId, setSelectedThemeId] = useState<string>('');
const [selectedDate, setSelectedDate] = useState<string>(new Date().toLocaleDateString('en-CA'));
const [isAdding, setIsAdding] = useState(false);
const [newScheduleTime, setNewScheduleTime] = useState('');
const [expandedScheduleId, setExpandedScheduleId] = useState<string | null>(null);
const [detailedSchedules, setDetailedSchedules] = useState<{ [key: string]: ScheduleDetailRetrieveResponse }>({});
const [isLoadingDetails, setIsLoadingDetails] = useState<boolean>(false);
const [isEditing, setIsEditing] = useState(false);
const [editingSchedule, setEditingSchedule] = useState<ScheduleDetailRetrieveResponse | null>(null);
const navigate = useNavigate();
const location = useLocation();
const handleError = (err: any) => {
if (isLoginRequiredError(err)) {
alert('로그인이 필요해요.');
navigate('/login', { state: { from: location } });
} else {
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
alert(message);
console.error(err);
}
};
useEffect(() => {
fetchAdminThemes()
.then(res => {
setThemes(res.themes);
if (res.themes.length > 0) {
setSelectedThemeId(String(res.themes[0].id));
}
})
.catch(handleError);
}, []);
const fetchSchedules = () => {
if (selectedDate && selectedThemeId) {
findSchedules(selectedDate, selectedThemeId)
.then(res => setSchedules(res.schedules))
.catch(err => {
setSchedules([]);
if (err.response?.status !== 404) {
handleError(err);
}
});
}
}
useEffect(() => {
fetchSchedules();
}, [selectedDate, selectedThemeId]);
const handleAddSchedule = async () => {
if (!newScheduleTime) {
alert('시간을 입력해주세요.');
return;
}
if (!/\d{2}:\d{2}/.test(newScheduleTime)) {
alert('시간 형식이 올바르지 않습니다. HH:MM 형식으로 입력해주세요.');
return;
}
try {
await createSchedule({
date: selectedDate,
themeId: selectedThemeId,
time: newScheduleTime,
});
fetchSchedules();
setIsAdding(false);
setNewScheduleTime('');
} catch (error) {
handleError(error);
}
};
const handleDeleteSchedule = async (scheduleId: string) => {
if (window.confirm('정말 이 일정을 삭제하시겠습니까?')) {
try {
await deleteSchedule(scheduleId);
setSchedules(schedules.filter(s => s.id !== scheduleId));
setExpandedScheduleId(null); // Close the details view after deletion
} catch (error) {
handleError(error);
}
}
};
const handleToggleDetails = async (scheduleId: string) => {
const isAlreadyExpanded = expandedScheduleId === scheduleId;
setIsEditing(false); // Reset editing state whenever toggling
if (isAlreadyExpanded) {
setExpandedScheduleId(null);
} else {
setExpandedScheduleId(scheduleId);
if (!detailedSchedules[scheduleId]) {
setIsLoadingDetails(true);
try {
const details = await findScheduleById(scheduleId);
setDetailedSchedules(prev => ({ ...prev, [scheduleId]: details }));
} catch (error) {
handleError(error);
} finally {
setIsLoadingDetails(false);
}
}
}
};
const handleEditClick = () => {
if (expandedScheduleId && detailedSchedules[expandedScheduleId]) {
setEditingSchedule({ ...detailedSchedules[expandedScheduleId] });
setIsEditing(true);
}
};
const handleCancelEdit = () => {
setIsEditing(false);
setEditingSchedule(null);
};
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
if (editingSchedule) {
setEditingSchedule({ ...editingSchedule, [name]: value });
}
};
const handleSave = async () => {
if (!editingSchedule) return;
try {
await updateSchedule(editingSchedule.id, {
time: editingSchedule.time,
status: editingSchedule.status,
});
// Refresh data
const details = await findScheduleById(editingSchedule.id);
setDetailedSchedules(prev => ({ ...prev, [editingSchedule.id]: details }));
setSchedules(schedules.map(s => s.id === editingSchedule.id ? { ...s, time: details.time, status: details.status } : s));
alert('일정이 성공적으로 업데이트되었습니다.');
setIsEditing(false);
} catch (error) {
handleError(error);
}
};
return (
<div className="admin-schedule-container">
<h2 className="page-title"> </h2>
<div className="schedule-controls">
<div className="form-group">
<label className="form-label" htmlFor="date-filter"></label>
<input
id="date-filter"
type="date"
className="form-input"
value={selectedDate}
onChange={e => setSelectedDate(e.target.value)}
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="theme-filter"></label>
<select
id="theme-filter"
className="form-select"
value={selectedThemeId}
onChange={e => setSelectedThemeId(e.target.value)}
>
{themes.map(theme => (
<option key={theme.id} value={theme.id}>{theme.name}</option>
))}
</select>
</div>
</div>
<div className="section-card">
<div className="table-header">
<button className="btn btn-primary" onClick={() => setIsAdding(true)}> </button>
</div>
<div className="table-container">
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{schedules.map(schedule => (
<Fragment key={schedule.id}>
<tr>
<td>{schedule.time}</td>
<td>{getScheduleStatusText(schedule.status)}</td>
<td className="action-buttons">
<button
className="btn btn-secondary"
onClick={() => handleToggleDetails(schedule.id)}
>
{expandedScheduleId === schedule.id ? '닫기' : '상세'}
</button>
</td>
</tr>
{expandedScheduleId === schedule.id && (
<tr className="schedule-details-row">
<td colSpan={3}>
{isLoadingDetails ? (
<p> ...</p>
) : detailedSchedules[schedule.id] ? (
<div className="details-form-container">
<div className="audit-info">
<h4 className="audit-title"> </h4>
<div className="audit-body">
<p><strong>:</strong> {new Date(detailedSchedules[schedule.id].createdAt).toLocaleString()}</p>
<p><strong>:</strong> {new Date(detailedSchedules[schedule.id].updatedAt).toLocaleString()}</p>
<p><strong>:</strong> {detailedSchedules[schedule.id].createdBy}</p>
<p><strong>:</strong> {detailedSchedules[schedule.id].updatedBy}</p>
</div>
</div>
{isEditing && editingSchedule ? (
// --- EDIT MODE ---
<div className="form-card">
<div className="form-section">
<div className="form-row">
<div className="form-group">
<label className="form-label"></label>
<input type="time" name="time" className="form-input" value={editingSchedule.time} onChange={handleEditChange} />
</div>
<div className="form-group">
<label className="form-label"></label>
<select name="status" className="form-select" value={editingSchedule.status} onChange={handleEditChange}>
{Object.values(ScheduleStatus).map(s => <option key={s} value={s}>{getScheduleStatusText(s)}</option>)}
</select>
</div>
</div>
</div>
<div className="button-group">
<button type="button" className="btn btn-secondary" onClick={handleCancelEdit}></button>
<button type="button" className="btn btn-primary" onClick={handleSave}></button>
</div>
</div>
) : (
// --- VIEW MODE ---
<div className="button-group view-mode-buttons">
<button type="button" className="btn btn-danger" onClick={() => handleDeleteSchedule(schedule.id)}></button>
<button type="button" className="btn btn-primary" onClick={handleEditClick}></button>
</div>
)}
</div>
) : (
<p> .</p>
)}
</td>
</tr>
)}
</Fragment>
))}
{isAdding && (
<tr className="editing-row">
<td>
<input
type="time"
className="form-input"
value={newScheduleTime}
onChange={e => setNewScheduleTime(e.target.value)}
/>
</td>
<td></td>
<td className="action-buttons">
<button className="btn btn-primary" onClick={handleAddSchedule}></button>
<button className="btn btn-secondary" onClick={() => setIsAdding(false)}></button>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default AdminSchedulePage;

View File

@ -1,18 +1,18 @@
import { isLoginRequiredError } from '@_api/apiClient'; import { isLoginRequiredError } from '@_api/apiClient';
import { createPendingReservation } from '@_api/reservation/reservationAPI'; import { createPendingReservation } from '@_api/reservation/reservationAPI';
import { fetchUserThemes } from '@_api/theme/themeAPI'; import { findAvailableThemesByDate, findSchedules } from '@_api/schedule/scheduleAPI';
import { fetchTimesWithAvailability } from '@_api/time/timeAPI'; import type { ScheduleRetrieveResponse } from '@_api/schedule/scheduleTypes';
import type { TimeWithAvailabilityResponse } from '@_api/time/timeTypes'; import { findThemesByIds } from '@_api/theme/themeAPI';
import { Difficulty } from '@_api/theme/themeTypes';
import '@_css/reservation-v2-1.css'; import '@_css/reservation-v2-1.css';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { formatDate, formatTime } from 'src/util/DateTimeFormatter'; import { formatDate, formatTime } from 'src/util/DateTimeFormatter';
// New theme type based on the provided schema
interface ThemeV21 { interface ThemeV21 {
id: string; // Changed to number to match API id: string;
name: string; name: string;
difficulty: string; difficulty: Difficulty;
description: string; description: string;
thumbnailUrl: string; thumbnailUrl: string;
price: number; price: number;
@ -23,13 +23,30 @@ interface ThemeV21 {
availableMinutes: number; availableMinutes: number;
} }
const getDifficultyText = (difficulty: Difficulty): string => {
switch (difficulty) {
case Difficulty.VERY_EASY:
return '매우 쉬움';
case Difficulty.EASY:
return '쉬움';
case Difficulty.NORMAL:
return '보통';
case Difficulty.HARD:
return '어려움';
case Difficulty.VERY_HARD:
return '매우 어려움';
default:
return difficulty;
}
};
const ReservationStep1PageV21: React.FC = () => { const ReservationStep1PageV21: React.FC = () => {
const [selectedDate, setSelectedDate] = useState<Date>(new Date()); const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [viewDate, setViewDate] = useState<Date>(new Date()); // For carousel
const [themes, setThemes] = useState<ThemeV21[]>([]); const [themes, setThemes] = useState<ThemeV21[]>([]);
const [selectedTheme, setSelectedTheme] = useState<ThemeV21 | null>(null); const [selectedTheme, setSelectedTheme] = useState<ThemeV21 | null>(null);
const [times, setTimes] = useState<TimeWithAvailabilityResponse[]>([]); const [schedules, setSchedules] = useState<ScheduleRetrieveResponse[]>([]);
const [selectedTime, setSelectedTime] = useState<TimeWithAvailabilityResponse | null>(null); const [selectedSchedule, setSelectedSchedule] = useState<ScheduleRetrieveResponse | null>(null);
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false); const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
@ -47,49 +64,47 @@ const ReservationStep1PageV21: React.FC = () => {
}; };
useEffect(() => { useEffect(() => {
const fetchData = async () => { if (selectedDate) {
try { const dateStr = selectedDate.toLocaleDateString('en-CA'); // yyyy-mm-dd
const response = await fetchUserThemes(); findAvailableThemesByDate(dateStr)
// Map UserThemeRetrieveResponse to ThemeV21 .then(res => {
const mappedThemes: ThemeV21[] = response.themes.map(theme => ({ const themeIds: string[] = res.themeIds;
id: theme.id, if (themeIds.length > 0) {
name: theme.name, return findThemesByIds({ themeIds });
difficulty: theme.difficulty, } else {
description: theme.description, return Promise.resolve({ themes: [] });
thumbnailUrl: theme.thumbnailUrl, }
price: theme.price, })
minParticipants: theme.minParticipants, .then(themeResponse => {
maxParticipants: theme.maxParticipants, setThemes(themeResponse.themes as ThemeV21[]);
expectedMinutesFrom: theme.expectedMinutesFrom, })
expectedMinutesTo: theme.expectedMinutesTo, .catch(handleError)
availableMinutes: theme.availableMinutes, .finally(() => {
})); setSelectedTheme(null);
setThemes(mappedThemes); setSchedules([]);
} catch (error) { setSelectedSchedule(null);
handleError(error); });
} }
}; }, [selectedDate]);
fetchData();
}, []);
useEffect(() => { useEffect(() => {
if (selectedDate && selectedTheme) { if (selectedDate && selectedTheme) {
const dateStr = selectedDate.toLocaleDateString('en-CA'); const dateStr = selectedDate.toLocaleDateString('en-CA');
fetchTimesWithAvailability(dateStr, selectedTheme.id) findSchedules(dateStr, selectedTheme.id)
.then(res => { .then(res => {
setTimes(res.times); setSchedules(res.schedules);
setSelectedTime(null); setSelectedSchedule(null);
}) })
.catch(handleError); .catch(handleError);
} }
}, [selectedDate, selectedTheme]); }, [selectedDate, selectedTheme]);
const handleNextStep = () => { const handleNextStep = () => {
if (!selectedDate || !selectedTheme || !selectedTime) { if (!selectedDate || !selectedTheme || !selectedSchedule) {
alert('날짜, 테마, 시간을 모두 선택해주세요.'); alert('날짜, 테마, 시간을 모두 선택해주세요.');
return; return;
} }
if (!selectedTime.isAvailable) { if (selectedSchedule.status !== 'AVAILABLE') {
alert('예약할 수 없는 시간입니다.'); alert('예약할 수 없는 시간입니다.');
return; return;
} }
@ -97,45 +112,85 @@ const ReservationStep1PageV21: React.FC = () => {
}; };
const handleConfirmPayment = () => { const handleConfirmPayment = () => {
if (!selectedDate || !selectedTheme || !selectedTime) return; if (!selectedDate || !selectedTheme || !selectedSchedule) return;
const reservationData = { const reservationData = {
date: selectedDate.toLocaleDateString('en-CA'), scheduleId: selectedSchedule.id,
themeId: selectedTheme.id,
timeId: selectedTime.id,
}; };
createPendingReservation(reservationData) createPendingReservation(reservationData)
.then((res) => { .then((res) => {
navigate('/v2/reservation/payment', { state: { reservation: res } }); navigate('/v2-1/reservation/payment', { state: { reservation: res } });
}) })
.catch(handleError) .catch(handleError)
.finally(() => setIsModalOpen(false)); .finally(() => setIsConfirmModalOpen(false));
}; };
const renderDateOptions = () => { const handleDateSelect = (date: Date) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
if (date < today) {
alert("지난 날짜는 선택할 수 없습니다.");
return;
}
setSelectedDate(date);
}
const renderDateCarousel = () => {
const dates = []; const dates = [];
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0);
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
const date = new Date(today); const date = new Date(viewDate);
date.setDate(today.getDate() + i); date.setDate(viewDate.getDate() + i);
dates.push(date); dates.push(date);
} }
return dates.map(date => { const handlePrev = () => {
const isSelected = selectedDate.toDateString() === date.toDateString(); const newViewDate = new Date(viewDate);
return ( newViewDate.setDate(viewDate.getDate() - 1);
<div if (newViewDate < today) {
key={date.toISOString()} alert("지난 날짜는 조회할 수 없습니다.");
className={`date-option ${isSelected ? 'active' : ''}`} return;
onClick={() => setSelectedDate(date)} }
> setViewDate(newViewDate);
<div className="day-of-week">{['일', '월', '화', '수', '목', '금', '토'][date.getDay()]}</div> }
<div className="day">{date.getDate()}</div>
const handleNext = () => {
const newViewDate = new Date(viewDate);
newViewDate.setDate(viewDate.getDate() + 1);
setViewDate(newViewDate);
}
const goToToday = () => {
setViewDate(new Date());
setSelectedDate(new Date());
}
return (
<div className="date-carousel">
<button onClick={handlePrev} className="carousel-arrow"></button>
<div className="date-options-container">
{dates.map(date => {
const isSelected = selectedDate.toDateString() === date.toDateString();
const isPast = date < today;
return (
<div
key={date.toISOString()}
className={`date-option ${isSelected ? 'active' : ''} ${isPast ? 'disabled' : ''}`}
onClick={() => handleDateSelect(date)}
>
<div className="day-of-week">{['일', '월', '화', '수', '목', '금', '토'][date.getDay()]}</div>
<div className="day-circle">{date.getDate()}</div>
</div>
);
})}
</div> </div>
); <button onClick={handleNext} className="carousel-arrow"></button>
}); <button onClick={goToToday} className="today-button"></button>
</div>
);
}; };
const openThemeModal = (theme: ThemeV21) => { const openThemeModal = (theme: ThemeV21) => {
@ -143,7 +198,7 @@ const ReservationStep1PageV21: React.FC = () => {
setIsThemeModalOpen(true); setIsThemeModalOpen(true);
}; };
const isButtonDisabled = !selectedDate || !selectedTheme || !selectedTime || !selectedTime.isAvailable; const isButtonDisabled = !selectedDate || !selectedTheme || !selectedSchedule || selectedSchedule.status !== 'AVAILABLE';
return ( return (
<div className="reservation-v21-container"> <div className="reservation-v21-container">
@ -151,7 +206,7 @@ const ReservationStep1PageV21: React.FC = () => {
<div className="step-section"> <div className="step-section">
<h3>1. </h3> <h3>1. </h3>
<div className="date-selector">{renderDateOptions()}</div> {renderDateCarousel()}
</div> </div>
<div className={`step-section ${!selectedDate ? 'disabled' : ''}`}> <div className={`step-section ${!selectedDate ? 'disabled' : ''}`}>
@ -166,9 +221,9 @@ const ReservationStep1PageV21: React.FC = () => {
<div className="theme-info"> <div className="theme-info">
<h4>{theme.name}</h4> <h4>{theme.name}</h4>
<div className="theme-meta"> <div className="theme-meta">
<p><strong>:</strong> {theme.difficulty}</p>
<p><strong> :</strong> {theme.minParticipants} ~ {theme.maxParticipants}</p>
<p><strong>:</strong> {theme.price.toLocaleString()}</p> <p><strong>:</strong> {theme.price.toLocaleString()}</p>
<p><strong>:</strong> {getDifficultyText(theme.difficulty)}</p>
<p><strong> :</strong> {theme.minParticipants} ~ {theme.maxParticipants}</p>
<p><strong> :</strong> {theme.expectedMinutesFrom} ~ {theme.expectedMinutesTo}</p> <p><strong> :</strong> {theme.expectedMinutesFrom} ~ {theme.expectedMinutesTo}</p>
<p><strong> :</strong> {theme.availableMinutes}</p> <p><strong> :</strong> {theme.availableMinutes}</p>
</div> </div>
@ -182,14 +237,14 @@ const ReservationStep1PageV21: React.FC = () => {
<div className={`step-section ${!selectedTheme ? 'disabled' : ''}`}> <div className={`step-section ${!selectedTheme ? 'disabled' : ''}`}>
<h3>3. </h3> <h3>3. </h3>
<div className="time-slots"> <div className="time-slots">
{times.length > 0 ? times.map(time => ( {schedules.length > 0 ? schedules.map(schedule => (
<div <div
key={time.id} key={schedule.id}
className={`time-slot ${selectedTime?.id === time.id ? 'active' : ''} ${!time.isAvailable ? 'disabled' : ''}`} className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== 'AVAILABLE' ? 'disabled' : ''}`}
onClick={() => time.isAvailable && setSelectedTime(time)} onClick={() => schedule.status === 'AVAILABLE' && setSelectedSchedule(schedule)}
> >
{time.startAt} {schedule.time}
<span className="time-availability">{time.isAvailable ? '예약가능' : '예약불가'}</span> <span className="time-availability">{schedule.status === 'AVAILABLE' ? '예약가능' : '예약불가'}</span>
</div> </div>
)) : <div className="no-times"> .</div>} )) : <div className="no-times"> .</div>}
</div> </div>
@ -209,7 +264,7 @@ const ReservationStep1PageV21: React.FC = () => {
<h2>{selectedTheme.name}</h2> <h2>{selectedTheme.name}</h2>
<div className="modal-section"> <div className="modal-section">
<h3> </h3> <h3> </h3>
<p><strong>:</strong> {selectedTheme.difficulty}</p> <p><strong>:</strong> {getDifficultyText(selectedTheme.difficulty)}</p>
<p><strong> :</strong> {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}</p> <p><strong> :</strong> {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}</p>
<p><strong> :</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}</p> <p><strong> :</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}</p>
<p><strong>:</strong> {selectedTheme.price.toLocaleString()}</p> <p><strong>:</strong> {selectedTheme.price.toLocaleString()}</p>
@ -230,7 +285,7 @@ const ReservationStep1PageV21: React.FC = () => {
<div className="modal-section"> <div className="modal-section">
<p><strong>:</strong> {formatDate(selectedDate!!.toLocaleDateString('ko-KR'))}</p> <p><strong>:</strong> {formatDate(selectedDate!!.toLocaleDateString('ko-KR'))}</p>
<p><strong>:</strong> {selectedTheme!!.name}</p> <p><strong>:</strong> {selectedTheme!!.name}</p>
<p><strong>:</strong> {formatTime(selectedTime!!.startAt)}</p> <p><strong>:</strong> {formatTime(selectedSchedule!!.time)}</p>
<p><strong>:</strong> {selectedTheme!!.price.toLocaleString()}</p> <p><strong>:</strong> {selectedTheme!!.price.toLocaleString()}</p>
</div> </div>
<div className="modal-actions"> <div className="modal-actions">

View File

@ -30,7 +30,6 @@ class JacksonConfig {
.registerModule(javaTimeModule()) .registerModule(javaTimeModule())
.registerModule(dateTimeModule()) .registerModule(dateTimeModule())
.registerModule(kotlinModule()) .registerModule(kotlinModule())
.registerModule(longIdModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
private fun javaTimeModule(): JavaTimeModule = JavaTimeModule() private fun javaTimeModule(): JavaTimeModule = JavaTimeModule()
@ -51,13 +50,6 @@ class JacksonConfig {
LocalTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm")) LocalTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm"))
) as JavaTimeModule ) as JavaTimeModule
private fun longIdModule(): SimpleModule {
val simpleModule = SimpleModule()
simpleModule.addSerializer(Long::class.java, LongToStringSerializer())
simpleModule.addDeserializer(Long::class.java, StringToLongDeserializer())
return simpleModule
}
private fun dateTimeModule(): SimpleModule { private fun dateTimeModule(): SimpleModule {
val simpleModule = SimpleModule() val simpleModule = SimpleModule()
simpleModule.addSerializer(LocalDateTime::class.java, LocalDateTimeSerializer()) simpleModule.addSerializer(LocalDateTime::class.java, LocalDateTimeSerializer())

View File

@ -24,7 +24,7 @@ class ControllerLoggingAspect(
private val messageConverter: ApiLogMessageConverter, private val messageConverter: ApiLogMessageConverter,
) { ) {
@Pointcut("execution(* roomescape..web..*Controller.*(..))") @Pointcut("execution(* roomescape..web..*Controller*.*(..))")
fun allController() { fun allController() {
} }

View File

@ -0,0 +1,130 @@
package roomescape.schedule.business
import ScheduleException
import com.github.f4b6a3.tsid.TsidFactory
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import roomescape.common.config.next
import roomescape.member.business.MemberService
import roomescape.schedule.exception.ScheduleErrorCode
import roomescape.schedule.infrastructure.persistence.ScheduleEntity
import roomescape.schedule.infrastructure.persistence.ScheduleRepository
import roomescape.schedule.infrastructure.persistence.ScheduleStatus
import roomescape.schedule.web.*
import java.time.LocalDate
private val log: KLogger = KotlinLogging.logger {}
@Service
class ScheduleService(
private val scheduleRepository: ScheduleRepository,
private val scheduleValidator: ScheduleValidator,
private val tsidFactory: TsidFactory,
private val memberService: MemberService
) {
@Transactional(readOnly = true)
fun findThemesByDate(date: LocalDate): AvailableThemeIdListResponse {
log.info { "[ScheduleService.findThemesByDate] 동일한 날짜의 모든 테마 조회: date=$date" }
return scheduleRepository.findAllByDate(date)
.toThemeIdListResponse()
.also {
log.info { "[ScheduleService.findThemesByDate] date=${date}${it.themeIds.size}개 테마 조회 완료" }
}
}
@Transactional(readOnly = true)
fun findSchedules(date: LocalDate, themeId: Long): ScheduleRetrieveListResponse {
log.info { "[ScheduleService.findSchedules] 동일한 날짜와 테마인 모든 일정 조회: date=${date}, themeId=${themeId}" }
return scheduleRepository.findAllByDateAndThemeId(date, themeId)
.toRetrieveListResponse()
.also {
log.info { "[ScheduleService.findSchedules] date=${date}, themeId=${themeId}${it.schedules.size}개 일정 조회 완료" }
}
}
@Transactional(readOnly = true)
fun findDetail(id: Long): ScheduleDetailRetrieveResponse {
log.info { "[ScheduleService.findDetail] 일정 상세 정보조회 시작: id=$id" }
val schedule: ScheduleEntity = findOrThrow(id)
val createdBy = memberService.findById(schedule.createdBy).name
val updatedBy = memberService.findById(schedule.updatedBy).name
return schedule.toDetailRetrieveResponse(createdBy, updatedBy)
.also {
log.info { "[ScheduleService.findDetail] 일정 상세 조회 완료: id=$id" }
}
}
@Transactional
fun createSchedule(request: ScheduleCreateRequest): ScheduleCreateResponse {
log.info { "[ScheduleService.createSchedule] 일정 생성 시작: date=${request.date}, time=${request.time}, themeId=${request.themeId}" }
scheduleValidator.validateCanCreate(request)
val schedule = ScheduleEntity(
id = tsidFactory.next(),
date = request.date,
time = request.time,
themeId = request.themeId,
status = ScheduleStatus.AVAILABLE
)
return ScheduleCreateResponse(scheduleRepository.save(schedule).id)
.also {
log.info { "[ScheduleService.createSchedule] 일정 생성 완료: id=${it.id}" }
}
}
@Transactional
fun updateSchedule(id: Long, request: ScheduleUpdateRequest) {
log.info { "[ScheduleService.updateSchedule] 일정 수정 시작: id=$id, request=${request}" }
if (request.isAllParamsNull()) {
log.info { "[ScheduleService.updateSchedule] 일정 변경 사항 없음: id=$id" }
return
}
val schedule: ScheduleEntity = findOrThrow(id)
scheduleValidator.validateCanUpdate(schedule, request)
schedule.modifyIfNotNull(
request.time,
request.status
).also {
log.info { "[ScheduleService.updateSchedule] 일정 수정 완료: id=$id, request=${request}" }
}
}
@Transactional
fun deleteSchedule(id: Long) {
log.info { "[ScheduleService.deleteSchedule] 일정 삭제 시작: id=$id" }
val schedule: ScheduleEntity = findOrThrow(id)
scheduleValidator.validateCanDelete(schedule)
scheduleRepository.delete(schedule).also {
log.info { "[ScheduleService.deleteSchedule] 일정 삭제 완료: id=$id" }
}
}
private fun findOrThrow(id: Long): ScheduleEntity {
log.info { "[ScheduleService.findOrThrow] 일정 조회 시작: id=$id" }
return scheduleRepository.findByIdOrNull(id)
?.also { log.info { "[ScheduleService.findOrThrow] 일정 조회 완료: id=$id" } }
?: run {
log.warn { "[ScheduleService.updateSchedule] 일정 조회 실패. id=$id" }
throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND)
}
}
}

View File

@ -0,0 +1,67 @@
package roomescape.schedule.business
import ScheduleException
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import roomescape.schedule.exception.ScheduleErrorCode
import roomescape.schedule.infrastructure.persistence.ScheduleEntity
import roomescape.schedule.infrastructure.persistence.ScheduleRepository
import roomescape.schedule.infrastructure.persistence.ScheduleStatus
import roomescape.schedule.web.ScheduleCreateRequest
import roomescape.schedule.web.ScheduleUpdateRequest
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
private val log: KLogger = KotlinLogging.logger {}
@Component
class ScheduleValidator(
private val scheduleRepository: ScheduleRepository
) {
fun validateCanDelete(schedule: ScheduleEntity) {
val status: ScheduleStatus = schedule.status
if (status !in listOf(ScheduleStatus.AVAILABLE,ScheduleStatus.BLOCKED)) {
log.info { "[ScheduleValidator.validateCanDelete] 삭제 실패: id=${schedule.id} / status=${status}" }
throw ScheduleException(ScheduleErrorCode.SCHEDULE_IN_USE)
}
}
fun validateCanUpdate(schedule: ScheduleEntity, request: ScheduleUpdateRequest) {
val date: LocalDate = schedule.date
val time: LocalTime = request.time ?: schedule.time
validateDateTime(date, time)
}
fun validateCanCreate(request: ScheduleCreateRequest) {
val date: LocalDate = request.date
val time: LocalTime = request.time
val themeId: Long = request.themeId
validateAlreadyExists(date, themeId, time)
validateDateTime(date, time)
}
private fun validateAlreadyExists(date: LocalDate, themeId: Long, time: LocalTime) {
if (scheduleRepository.existsByDateAndThemeIdAndTime(date, themeId, time)) {
log.info {
"[ScheduleValidator.validateAlreadyExists] 동일한 날짜, 테마, 시간 존재로 인한 실패: date=${date} / themeId=${themeId} / time=${time}"
}
throw ScheduleException(ScheduleErrorCode.SCHEDULE_ALREADY_EXISTS)
}
}
private fun validateDateTime(date: LocalDate, time: LocalTime) {
val dateTime = LocalDateTime.of(date, time)
if (dateTime.isBefore(LocalDateTime.now())) {
log.info {
"[ScheduleValidator.validateDateTime] 이전 시간 선택으로 인한 실패: date=${date} / time=${time}"
}
throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME)
}
}
}

View File

@ -0,0 +1,69 @@
package roomescape.schedule.docs
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
import jakarta.validation.Valid
import org.springframework.format.annotation.DateTimeFormat
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestParam
import roomescape.auth.web.support.Admin
import roomescape.auth.web.support.LoginRequired
import roomescape.common.dto.response.CommonApiResponse
import roomescape.schedule.web.*
import java.time.LocalDate
interface ScheduleAPI {
@LoginRequired
@Operation(summary = "입력된 날짜에 가능한 테마 목록 조회", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "입력된 날짜에 가능한 테마 목록 조회", useReturnTypeSchema = true))
fun findAvailableThemes(
@RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate
): ResponseEntity<CommonApiResponse<AvailableThemeIdListResponse>>
@LoginRequired
@Operation(summary = "입력된 날짜, 테마에 대한 모든 시간 조회", tags = ["로그인이 필요한 API"])
@ApiResponses(
ApiResponse(
responseCode = "200",
description = "입력된 날짜, 테마에 대한 모든 시간 조회",
useReturnTypeSchema = true
)
)
fun findAllTime(
@RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate,
@RequestParam("themeId") themeId: Long
): ResponseEntity<CommonApiResponse<ScheduleRetrieveListResponse>>
@Admin
@Operation(summary = "일정 상세 조회", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "감사 정보를 포함하여 일정 상세 조회", useReturnTypeSchema = true))
fun findScheduleDetail(
@PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<ScheduleDetailRetrieveResponse>>
@Admin
@Operation(summary = "일정 생성", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun createSchedule(
@Valid @RequestBody request: ScheduleCreateRequest
): ResponseEntity<CommonApiResponse<ScheduleCreateResponse>>
@Admin
@Operation(summary = "일정 수정", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun updateSchedule(
@PathVariable("id") id: Long,
@Valid @RequestBody request: ScheduleUpdateRequest
): ResponseEntity<CommonApiResponse<Unit>>
@Admin
@Operation(summary = "일정 삭제", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true))
fun deleteSchedule(
@PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<Unit>>
}

View File

@ -0,0 +1,15 @@
package roomescape.schedule.exception
import org.springframework.http.HttpStatus
import roomescape.common.exception.ErrorCode
enum class ScheduleErrorCode(
override val httpStatus: HttpStatus,
override val errorCode: String,
override val message: String
) : ErrorCode {
SCHEDULE_NOT_FOUND(HttpStatus.NOT_FOUND, "S001", "일정을 찾을 수 없어요."),
SCHEDULE_ALREADY_EXISTS(HttpStatus.CONFLICT, "S002", "이미 동일한 일정이 있어요."),
PAST_DATE_TIME(HttpStatus.BAD_REQUEST, "S003", "과거 날짜와 시간은 선택할 수 없어요."),
SCHEDULE_IN_USE(HttpStatus.CONFLICT, "S004", "예약이 진행중이거나 완료된 일정은 삭제할 수 없어요."),
}

View File

@ -0,0 +1,7 @@
import roomescape.common.exception.ErrorCode
import roomescape.common.exception.RoomescapeException
class ScheduleException(
override val errorCode: ErrorCode,
override val message: String = errorCode.message
) : RoomescapeException(errorCode, message)

View File

@ -0,0 +1,36 @@
package roomescape.schedule.infrastructure.persistence
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Table
import jakarta.persistence.UniqueConstraint
import roomescape.common.entity.AuditingBaseEntity
import java.time.LocalDate
import java.time.LocalTime
@Entity
@Table(name = "schedule", uniqueConstraints = [UniqueConstraint(columnNames = ["date", "time", "theme_id"])])
class ScheduleEntity(
id: Long,
var date: LocalDate,
var time: LocalTime,
var themeId: Long,
@Enumerated(value = EnumType.STRING)
var status: ScheduleStatus
) : AuditingBaseEntity(id) {
fun modifyIfNotNull(
time: LocalTime?,
status: ScheduleStatus?
) {
time?.let { this.time = it }
status?.let { this.status = it }
}
}
enum class ScheduleStatus {
AVAILABLE, PENDING, RESERVED, BLOCKED
}

View File

@ -0,0 +1,14 @@
package roomescape.schedule.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
import java.time.LocalDate
import java.time.LocalTime
interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
fun findAllByDate(date: LocalDate): List<ScheduleEntity>
fun findAllByDateAndThemeId(date: LocalDate, themeId: Long): List<ScheduleEntity>
fun existsByDateAndThemeIdAndTime(date: LocalDate, themeId: Long, time: LocalTime): Boolean
}

View File

@ -0,0 +1,71 @@
package roomescape.schedule.web
import jakarta.validation.Valid
import org.springframework.format.annotation.DateTimeFormat
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import roomescape.common.dto.response.CommonApiResponse
import roomescape.schedule.business.ScheduleService
import roomescape.schedule.docs.ScheduleAPI
import java.time.LocalDate
@RestController
class ScheduleController(
private val scheduleService: ScheduleService
) : ScheduleAPI {
@GetMapping("/schedules/themes")
override fun findAvailableThemes(
@RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate
): ResponseEntity<CommonApiResponse<AvailableThemeIdListResponse>> {
val response = scheduleService.findThemesByDate(date)
return ResponseEntity.ok(CommonApiResponse(response))
}
@GetMapping("/schedules")
override fun findAllTime(
@RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate,
@RequestParam("themeId") themeId: Long
): ResponseEntity<CommonApiResponse<ScheduleRetrieveListResponse>> {
val response = scheduleService.findSchedules(date, themeId)
return ResponseEntity.ok(CommonApiResponse(response))
}
@GetMapping("/schedules/{id}")
override fun findScheduleDetail(
@PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<ScheduleDetailRetrieveResponse>> {
val response = scheduleService.findDetail(id)
return ResponseEntity.ok(CommonApiResponse(response))
}
@PostMapping("/schedules")
override fun createSchedule(
@Valid @RequestBody request: ScheduleCreateRequest
): ResponseEntity<CommonApiResponse<ScheduleCreateResponse>> {
val response = scheduleService.createSchedule(request)
return ResponseEntity.ok(CommonApiResponse(response))
}
@PatchMapping("/schedules/{id}")
override fun updateSchedule(
@PathVariable("id") id: Long,
@Valid @RequestBody request: ScheduleUpdateRequest
): ResponseEntity<CommonApiResponse<Unit>> {
scheduleService.updateSchedule(id, request)
return ResponseEntity.ok(CommonApiResponse(Unit))
}
@DeleteMapping("/schedules/{id}")
override fun deleteSchedule(
@PathVariable("id") id: Long
): ResponseEntity<CommonApiResponse<Unit>> {
scheduleService.deleteSchedule(id)
return ResponseEntity.noContent().build()
}
}

View File

@ -0,0 +1,68 @@
package roomescape.schedule.web
import roomescape.schedule.infrastructure.persistence.ScheduleEntity
import roomescape.schedule.infrastructure.persistence.ScheduleStatus
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
data class AvailableThemeIdListResponse(
val themeIds: List<Long>
)
fun List<ScheduleEntity>.toThemeIdListResponse() = AvailableThemeIdListResponse(this.map { it.themeId })
data class ScheduleRetrieveResponse(
val id: Long,
val time: LocalTime,
val status: ScheduleStatus
)
data class ScheduleRetrieveListResponse(
val schedules: List<ScheduleRetrieveResponse>
)
fun List<ScheduleEntity>.toRetrieveListResponse() = ScheduleRetrieveListResponse(
this.map { ScheduleRetrieveResponse(it.id, it.time, it.status) }
)
data class ScheduleCreateRequest(
val date: LocalDate,
val time: LocalTime,
val themeId: Long
)
data class ScheduleCreateResponse(
val id: Long
)
data class ScheduleUpdateRequest(
val time: LocalTime? = null,
val status: ScheduleStatus? = null
) {
fun isAllParamsNull(): Boolean {
return time == null && status == null
}
}
data class ScheduleDetailRetrieveResponse(
val id: Long,
val date: LocalDate,
val time: LocalTime,
val status: ScheduleStatus,
val createdAt: LocalDateTime,
val createdBy: String,
val updatedAt: LocalDateTime,
val updatedBy: String,
)
fun ScheduleEntity.toDetailRetrieveResponse(createdBy: String, updatedBy: String) = ScheduleDetailRetrieveResponse(
id = this.id,
date = this.date,
time = this.time,
status = this.status,
createdAt = this.createdAt,
createdBy = createdBy,
updatedAt = this.updatedAt,
updatedBy = updatedBy
)

View File

@ -23,6 +23,16 @@ class ThemeServiceV2(
private val memberService: MemberService, private val memberService: MemberService,
private val themeValidator: ThemeValidatorV2 private val themeValidator: ThemeValidatorV2
) { ) {
@Transactional(readOnly = true)
fun findThemesByIds(request: ThemeListRetrieveRequest): ThemeRetrieveListResponseV2 {
log.info { "[ThemeService.findThemesByIds] 예약 페이지에서의 테마 목록 조회 시작: themeIds=${request.themeIds}" }
return request.themeIds
.map { findOrThrow(it) }
.toRetrieveListResponse()
.also { log.info { "[ThemeService.findThemesByIds] ${it.themes.size}개 테마 조회 완료" } }
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findThemesForReservation(): ThemeRetrieveListResponseV2 { fun findThemesForReservation(): ThemeRetrieveListResponseV2 {
log.info { "[ThemeService.findThemesForReservation] 예약 페이지에서의 테마 목록 조회 시작" } log.info { "[ThemeService.findThemesForReservation] 예약 페이지에서의 테마 목록 조회 시작" }
@ -43,19 +53,15 @@ class ThemeServiceV2(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findAdminThemeDetail(id: Long): AdminThemeDetailRetrieveResponse { fun findAdminThemeDetail(id: Long): AdminThemeDetailRetrieveResponse {
log.info { "[ThemeService.findAdminThemeDetail] 관리자 페이지에서의 테마 상세 정보 조회 시작" } log.info { "[ThemeService.findAdminThemeDetail] 관리자 페이지에서의 테마 상세 정보 조회 시작: id=${id}" }
val theme = themeRepository.findByIdOrNull(id) val theme: ThemeEntityV2 = findOrThrow(id)
?: run {
log.warn { "[ThemeService.findAdminThemeDetail] 테마 조회 실패. id=$id" }
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
}
val createdBy = memberService.findById(theme.createdBy).name val createdBy = memberService.findById(theme.createdBy).name
val updatedBy = memberService.findById(theme.updatedBy).name val updatedBy = memberService.findById(theme.updatedBy).name
return theme.toAdminThemeDetailResponse(createdBy, updatedBy) return theme.toAdminThemeDetailResponse(createdBy, updatedBy)
.also { log.info { "[ThemeService.findAdminThemeDetail] 테마 상세 조회 완료. id=$id, name=${theme.name}" } } .also { log.info { "[ThemeService.findAdminThemeDetail] 테마 상세 조회 완료: id=$id, name=${theme.name}" } }
} }
@Transactional @Transactional
@ -69,36 +75,33 @@ class ThemeServiceV2(
) )
return ThemeCreateResponseV2(theme.id).also { return ThemeCreateResponseV2(theme.id).also {
log.info { "[ThemeService.createTheme] 테마 생성 완료. id=${theme.id}, name=${theme.name}" } log.info { "[ThemeService.createTheme] 테마 생성 완료: id=${theme.id}, name=${theme.name}" }
} }
} }
@Transactional @Transactional
fun deleteTheme(id: Long) { fun deleteTheme(id: Long) {
log.info { "[ThemeService.deleteTheme] 테마 삭제 시작" } log.info { "[ThemeService.deleteTheme] 테마 삭제 시작: id=${id}" }
val theme = themeRepository.findByIdOrNull(id) val theme: ThemeEntityV2 = findOrThrow(id)
?: run {
log.warn { "[ThemeService.deleteTheme] 테마 조회 실패. id=$id" }
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
}
themeRepository.delete(theme).also { themeRepository.delete(theme).also {
log.info { "[ThemeService.deleteTheme] 테마 삭제 완료. id=$id, name=${theme.name}" } log.info { "[ThemeService.deleteTheme] 테마 삭제 완료: id=$id, name=${theme.name}" }
} }
} }
@Transactional @Transactional
fun updateTheme(id: Long, request: ThemeUpdateRequest) { fun updateTheme(id: Long, request: ThemeUpdateRequest) {
log.info { "[ThemeService.updateTheme] 테마 수정 시작" } log.info { "[ThemeService.updateTheme] 테마 수정 시작: id=${id}, request=${request}" }
if (request.isAllParamsNull()) {
log.info { "[ThemeService.updateTheme] 테마 변경 사항 없음: id=${id}" }
return
}
themeValidator.validateCanUpdate(request) themeValidator.validateCanUpdate(request)
val theme: ThemeEntityV2 = themeRepository.findByIdOrNull(id) val theme: ThemeEntityV2 = findOrThrow(id)
?: run {
log.warn { "[ThemeService.updateTheme] 테마 조회 실패. id=$id" }
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
}
theme.modifyIfNotNull( theme.modifyIfNotNull(
request.name, request.name,
@ -112,6 +115,19 @@ class ThemeServiceV2(
request.expectedMinutesFrom, request.expectedMinutesFrom,
request.expectedMinutesTo, request.expectedMinutesTo,
request.isOpen, request.isOpen,
) ).also {
log.info { "[ThemeService.updateTheme] 테마 수정 완료: id=$id, request=${request}" }
}
}
private fun findOrThrow(id: Long): ThemeEntityV2 {
log.info { "[ThemeService.findOrThrow] 테마 조회 시작: id=$id" }
return themeRepository.findByIdOrNull(id)
?.also { log.info { "[ThemeService.findOrThrow] 테마 조회 완료: id=$id" } }
?: run {
log.warn { "[ThemeService.updateTheme] 테마 조회 실패: id=$id" }
throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
}
} }
} }

View File

@ -32,7 +32,7 @@ class ThemeValidatorV2(
fun validateCanCreate(request: ThemeCreateRequestV2) { fun validateCanCreate(request: ThemeCreateRequestV2) {
if (themeRepository.existsByName(request.name)) { if (themeRepository.existsByName(request.name)) {
log.info { "[ThemeValidator.validateCanCreate] 이름 중복: name=${request.name}" } log.info { "[ThemeValidator.validateCanCreate] 이름 중복으로 인한 실패: name=${request.name}" }
throw ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED) throw ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED)
} }
@ -55,7 +55,7 @@ class ThemeValidatorV2(
maxParticipants: Short?, maxParticipants: Short?,
) { ) {
if (isNotNullAndBelowThan(price, MIN_PRICE)) { if (isNotNullAndBelowThan(price, MIN_PRICE)) {
log.info { "[ThemeValidator.validateCanCreate] 최소 가격 미달: price=${price}" } log.info { "[ThemeValidator.validateCanCreate] 최소 가격 미달로 인한 실패로 인한 실패: price=${price}" }
throw ThemeException(ThemeErrorCode.PRICE_BELOW_MINIMUM) throw ThemeException(ThemeErrorCode.PRICE_BELOW_MINIMUM)
} }
validateTimes(availableMinutes, expectedMinutesFrom, expectedMinutesTo) validateTimes(availableMinutes, expectedMinutesFrom, expectedMinutesTo)
@ -72,18 +72,18 @@ class ThemeValidatorV2(
|| isNotNullAndBelowThan(expectedMinutesTo, MIN_DURATION) || isNotNullAndBelowThan(expectedMinutesTo, MIN_DURATION)
) { ) {
log.info { log.info {
"[ThemeValidator.validateTimes] 최소 시간 미달: availableMinutes=$availableMinutes" + "[ThemeValidator.validateTimes] 최소 시간 미달로 인한 실패로 인한 실패: availableMinutes=$availableMinutes" +
", expectedMinutesFrom=$expectedMinutesFrom, expectedMinutesTo=$expectedMinutesTo" ", expectedMinutesFrom=$expectedMinutesFrom, expectedMinutesTo=$expectedMinutesTo"
} }
throw ThemeException(ThemeErrorCode.DURATION_BELOW_MINIMUM) throw ThemeException(ThemeErrorCode.DURATION_BELOW_MINIMUM)
} }
if (expectedMinutesFrom.isNotNullAndGraterThan(expectedMinutesTo)) { if (expectedMinutesFrom.isNotNullAndGraterThan(expectedMinutesTo)) {
log.info { "[ThemeValidator.validateTimes] 최소 예상 시간의 최대 예상 시간 초과: expectedMinutesFrom=$expectedMinutesFrom, expectedMinutesTo=$expectedMinutesTo" } log.info { "[ThemeValidator.validateTimes] 최소 예상 시간의 최대 예상 시간 초과로 인한 실패: expectedMinutesFrom=$expectedMinutesFrom, expectedMinutesTo=$expectedMinutesTo" }
throw ThemeException(ThemeErrorCode.MIN_EXPECTED_TIME_EXCEEDS_MAX_EXPECTED_TIME) throw ThemeException(ThemeErrorCode.MIN_EXPECTED_TIME_EXCEEDS_MAX_EXPECTED_TIME)
} }
if (expectedMinutesTo.isNotNullAndGraterThan(availableMinutes)) { if (expectedMinutesTo.isNotNullAndGraterThan(availableMinutes)) {
log.info { "[ThemeValidator.validateTimes] 예상 시간의 이용 가능 시간 초과: availableMinutes=$expectedMinutesFrom, expectedMinutesFrom=$expectedMinutesFrom, expectedMinutesTo=$expectedMinutesTo" } log.info { "[ThemeValidator.validateTimes] 예상 시간의 이용 가능 시간 초과로 인한 실패: availableMinutes=$expectedMinutesFrom, expectedMinutesFrom=$expectedMinutesFrom, expectedMinutesTo=$expectedMinutesTo" }
throw ThemeException(ThemeErrorCode.EXPECTED_TIME_EXCEEDS_AVAILABLE_TIME) throw ThemeException(ThemeErrorCode.EXPECTED_TIME_EXCEEDS_AVAILABLE_TIME)
} }
} }
@ -95,11 +95,11 @@ class ThemeValidatorV2(
if (isNotNullAndBelowThan(minParticipants, MIN_PARTICIPANTS) if (isNotNullAndBelowThan(minParticipants, MIN_PARTICIPANTS)
|| isNotNullAndBelowThan(maxParticipants, MIN_PARTICIPANTS) || isNotNullAndBelowThan(maxParticipants, MIN_PARTICIPANTS)
) { ) {
log.info { "[ThemeValidator.validateParticipants] 최소 인원 미달: minParticipants=$minParticipants, maxParticipants=$maxParticipants" } log.info { "[ThemeValidator.validateParticipants] 최소 인원 미달로 인한 실패: minParticipants=$minParticipants, maxParticipants=$maxParticipants" }
throw ThemeException(ThemeErrorCode.PARTICIPANT_BELOW_MINIMUM) throw ThemeException(ThemeErrorCode.PARTICIPANT_BELOW_MINIMUM)
} }
if (minParticipants.isNotNullAndGraterThan(maxParticipants)) { if (minParticipants.isNotNullAndGraterThan(maxParticipants)) {
log.info { "[ThemeValidator.validateParticipants] 최소 인원의 최대 인원 초과: minParticipants=$minParticipants, maxParticipants=$maxParticipants" } log.info { "[ThemeValidator.validateParticipants] 최소 인원의 최대 인원 초과로 인한 실패: minParticipants=$minParticipants, maxParticipants=$maxParticipants" }
throw ThemeException(ThemeErrorCode.MIN_PARTICIPANT_EXCEEDS_MAX_PARTICIPANT) throw ThemeException(ThemeErrorCode.MIN_PARTICIPANT_EXCEEDS_MAX_PARTICIPANT)
} }
} }

View File

@ -15,6 +15,7 @@ import roomescape.theme.web.AdminThemeDetailRetrieveResponse
import roomescape.theme.web.AdminThemeSummaryRetrieveListResponse import roomescape.theme.web.AdminThemeSummaryRetrieveListResponse
import roomescape.theme.web.ThemeCreateRequestV2 import roomescape.theme.web.ThemeCreateRequestV2
import roomescape.theme.web.ThemeCreateResponseV2 import roomescape.theme.web.ThemeCreateResponseV2
import roomescape.theme.web.ThemeListRetrieveRequest
import roomescape.theme.web.ThemeUpdateRequest import roomescape.theme.web.ThemeUpdateRequest
import roomescape.theme.web.ThemeRetrieveListResponseV2 import roomescape.theme.web.ThemeRetrieveListResponseV2
@ -33,7 +34,7 @@ interface ThemeAPIV2 {
@Admin @Admin
@Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun createTheme(@Valid @RequestBody themeCreateRequestV2: ThemeCreateRequestV2): ResponseEntity<CommonApiResponse<ThemeCreateResponseV2>> fun createTheme(@Valid @RequestBody themeCreateRequestV2: ThemeCreateRequestV2): ResponseEntity<CommonApiResponse<ThemeCreateResponseV2>>
@Admin @Admin
@ -53,4 +54,9 @@ interface ThemeAPIV2 {
@Operation(summary = "예약 페이지에서 모든 테마 조회", description = "모든 테마를 조회합니다.", tags = ["로그인이 필요한 API"]) @Operation(summary = "예약 페이지에서 모든 테마 조회", description = "모든 테마를 조회합니다.", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findUserThemes(): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponseV2>> fun findUserThemes(): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponseV2>>
@LoginRequired
@Operation(summary = "예약 페이지에서 입력한 날짜에 가능한 테마 조회", description = "입력한 날짜에 가능한 테마를 조회합니다.", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findThemesByIds(request: ThemeListRetrieveRequest): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponseV2>>
} }

View File

@ -12,6 +12,15 @@ class ThemeControllerV2(
private val themeService: ThemeServiceV2, private val themeService: ThemeServiceV2,
) : ThemeAPIV2 { ) : ThemeAPIV2 {
@PostMapping("/themes/retrieve")
override fun findThemesByIds(
@RequestBody request: ThemeListRetrieveRequest
): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponseV2>> {
val response = themeService.findThemesByIds(request)
return ResponseEntity.ok(CommonApiResponse(response))
}
@GetMapping("/v2/themes") @GetMapping("/v2/themes")
override fun findUserThemes(): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponseV2>> { override fun findUserThemes(): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponseV2>> {
val response = themeService.findThemesForReservation() val response = themeService.findThemesForReservation()

View File

@ -49,7 +49,21 @@ data class ThemeUpdateRequest(
val expectedMinutesFrom: Short? = null, val expectedMinutesFrom: Short? = null,
val expectedMinutesTo: Short? = null, val expectedMinutesTo: Short? = null,
val isOpen: Boolean? = null, val isOpen: Boolean? = null,
) ) {
fun isAllParamsNull(): Boolean {
return name == null &&
description == null &&
thumbnailUrl == null &&
difficulty == null &&
price == null &&
minParticipants == null &&
maxParticipants == null &&
availableMinutes == null &&
expectedMinutesFrom == null &&
expectedMinutesTo == null &&
isOpen == null
}
}
data class AdminThemeSummaryRetrieveResponse( data class AdminThemeSummaryRetrieveResponse(
val id: Long, val id: Long,
@ -114,6 +128,10 @@ fun ThemeEntityV2.toAdminThemeDetailResponse(createUserName: String, updateUserN
updatedBy = updateUserName updatedBy = updateUserName
) )
data class ThemeListRetrieveRequest(
val themeIds: List<Long>
)
data class ThemeRetrieveResponseV2( data class ThemeRetrieveResponseV2(
val id: Long, val id: Long,
val name: String, val name: String,

View File

@ -16,11 +16,11 @@
<appender-ref ref="CONSOLE"/> <appender-ref ref="CONSOLE"/>
</root> </root>
<logger name="roomescape" level="debug" additivity="false"> <logger name="roomescape" level="info" additivity="false">
<appender-ref ref="CONSOLE"/> <appender-ref ref="CONSOLE"/>
</logger> </logger>
<logger name="all-query-logger" level="debug" additivity="false"> <logger name="all-query-logger" level="info" additivity="false">
<appender-ref ref="CONSOLE"/> <appender-ref ref="CONSOLE"/>
</logger> </logger>
</included> </included>

View File

@ -1,5 +1,3 @@
CREATE UNIQUE INDEX IF NOT EXISTS idx_region_sido_sigungu_dong ON region(sido_code, sigungu_code, dong_code);
INSERT INTO region (code, sido_code, sigungu_code, dong_code, sido_name, sigungu_name, dong_name) INSERT INTO region (code, sido_code, sigungu_code, dong_code, sido_name, sigungu_name, dong_name)
VALUES ('1111010100', '11', '110', '10100', '서울특별시', '종로구', '청운동'), VALUES ('1111010100', '11', '110', '10100', '서울특별시', '종로구', '청운동'),
('1111010200', '11', '110', '10200', '서울특별시', '종로구', '신교동'), ('1111010200', '11', '110', '10200', '서울특별시', '종로구', '신교동'),

View File

@ -49,6 +49,23 @@ create table if not exists theme (
constraint fk_theme__updated_by foreign key (updated_by) references members (member_id) constraint fk_theme__updated_by foreign key (updated_by) references members (member_id)
); );
create table if not exists schedule (
id bigint primary key,
date date not null,
time time not null,
theme_id bigint not null,
status varchar(30) not null,
created_at timestamp not null,
created_by bigint not null,
updated_at timestamp not null,
updated_by bigint not null,
constraint uk_schedule__date_time_theme_id unique (date, time, theme_id),
constraint fk_schedule__created_by foreign key (created_by) references members (member_id),
constraint fk_schedule__updated_by foreign key (updated_by) references members (member_id),
constraint fk_schedule__theme_id foreign key (theme_id) references theme (id)
);
create table if not exists times ( create table if not exists times (
time_id bigint primary key, time_id bigint primary key,
start_at time not null, start_at time not null,

View File

@ -51,6 +51,23 @@ create table if not exists theme (
constraint fk_theme__updated_by foreign key (updated_by) references members (member_id) constraint fk_theme__updated_by foreign key (updated_by) references members (member_id)
); );
create table if not exists schedule (
id bigint primary key,
date date not null,
time time not null,
theme_id bigint not null,
status varchar(30) not null,
created_at datetime(6) not null,
created_by bigint not null,
updated_at datetime(6) not null,
updated_by bigint not null,
constraint uk_schedule__date_time_theme_id unique (date, time, theme_id),
constraint fk_schedule__created_by foreign key (created_by) references members (member_id),
constraint fk_schedule__updated_by foreign key (updated_by) references members (member_id),
constraint fk_schedule__theme_id foreign key (theme_id) references theme (id)
);
create table if not exists times create table if not exists times
( (
time_id bigint primary key, time_id bigint primary key,

View File

@ -0,0 +1,569 @@
package roomescape.schedule
import io.kotest.matchers.date.shouldBeAfter
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.restassured.module.kotlin.extensions.Extract
import io.restassured.module.kotlin.extensions.Given
import io.restassured.module.kotlin.extensions.When
import io.restassured.response.ValidatableResponse
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.CoreMatchers.notNullValue
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import roomescape.auth.exception.AuthErrorCode
import roomescape.member.infrastructure.persistence.Role
import roomescape.schedule.exception.ScheduleErrorCode
import roomescape.schedule.infrastructure.persistence.ScheduleEntity
import roomescape.schedule.infrastructure.persistence.ScheduleRepository
import roomescape.schedule.infrastructure.persistence.ScheduleStatus
import roomescape.schedule.web.ScheduleCreateRequest
import roomescape.schedule.web.ScheduleUpdateRequest
import roomescape.util.*
import java.time.LocalDate
import java.time.LocalTime
class ScheduleApiTest(
private val scheduleRepository: ScheduleRepository
) : FunSpecSpringbootTest() {
init {
context("관리자가 아니면 접근할 수 없다.") {
lateinit var token: String
beforeTest {
token = loginUtil.loginAsUser()
}
val commonAssertion: ValidatableResponse.() -> Unit = {
statusCode(HttpStatus.FORBIDDEN.value())
body(
"code",
equalTo(AuthErrorCode.ACCESS_DENIED.errorCode)
)
}
test("일정 상세: GET /schedules/{id}") {
runTest(
token = token,
on = {
get("/schedules/1")
},
expect = commonAssertion
)
}
test("일정 생성: POST /schedules") {
runTest(
token = token,
using = {
body(ScheduleFixture.createRequest)
},
on = {
get("/schedules/1")
},
expect = commonAssertion
)
}
test("일정 수정: PATCH /schedules/{id}") {
val createdSchedule: ScheduleEntity =
createDummySchedule(
ScheduleFixture.createRequest
)
runTest(
token = token,
using = {
body(ScheduleFixture.createRequest)
},
on = {
patch("/schedules/${createdSchedule.id}")
},
expect = commonAssertion
)
}
test("일정 삭제: DELETE /schedules/{id}") {
val createdSchedule: ScheduleEntity =
createDummySchedule(
ScheduleFixture.createRequest
)
runTest(
token = token,
on = {
delete("/schedules/${createdSchedule.id}")
},
expect = commonAssertion
)
}
}
context("일반 회원도 접근할 수 있다.") {
lateinit var token: String
beforeTest {
token = loginUtil.loginAsUser()
}
test("예약 가능 테마 조회: GET /schedules/themes?date={date}") {
val date = LocalDate.now().plusDays(1)
val time = LocalTime.now()
val createdSchedule: ScheduleEntity =
createDummySchedule(
ScheduleFixture.createRequest.copy(
date = date,
time = time
)
)
runTest(
token = token,
on = {
get("/schedules/themes?date=$date")
},
expect = {
statusCode(HttpStatus.OK.value())
body("data.themeIds.size()", equalTo(1))
body("data.themeIds[0]", equalTo(createdSchedule.themeId))
}
)
}
test("동일한 날짜, 테마에 대한 시간 조회: GET /schedules?date={date}&themeId={themeId}") {
val date = LocalDate.now().plusDays(1)
val time = LocalTime.now()
val createdSchedule: ScheduleEntity =
createDummySchedule(
ScheduleFixture.createRequest.copy(
date = date,
time = time
)
)
createDummySchedule(
ScheduleFixture.createRequest.copy(
date = date.plusDays(1L),
time = time,
themeId = createdSchedule.themeId
)
)
runTest(
token = token,
on = {
get("/schedules?date=$date&themeId=${createdSchedule.themeId}")
},
expect = {
statusCode(HttpStatus.OK.value())
body("data.schedules.size()", equalTo(1))
body("data.schedules[0].id", equalTo(createdSchedule.id))
assertProperties(
props = setOf("id", "time", "status"),
propsNameIfList = "schedules"
)
}
)
}
}
context("특정 날짜에 예약 가능한 테마 목록을 조회한다.") {
test("정상 응답") {
val date = LocalDate.now().plusDays(1)
for (i in 1..10) {
createDummySchedule(
ScheduleFixture.createRequest.copy(
date = date,
time = LocalTime.now().plusMinutes(i.toLong())
)
)
}
runTest(
token = loginUtil.loginAsUser(),
on = {
get("/schedules/themes?date=$date")
},
expect = {
statusCode(HttpStatus.OK.value())
body("data.themeIds.size()", equalTo(10))
}
)
}
}
context("동일한 날짜, 테마에 대한 모든 시간을 조회한다.") {
test("정상 응답") {
val date = LocalDate.now().plusDays(1)
val createdSchedule = createDummySchedule(
ScheduleFixture.createRequest.copy(
date = date,
time = LocalTime.now()
)
)
for (i in 1..10) {
createDummySchedule(
ScheduleFixture.createRequest.copy(
date = date,
time = LocalTime.now().plusMinutes(i.toLong()),
themeId = createdSchedule.themeId
)
)
}
runTest(
token = loginUtil.loginAsUser(),
on = {
get("/schedules?date=$date&themeId=${createdSchedule.themeId}")
},
expect = {
statusCode(HttpStatus.OK.value())
body("data.schedules.size()", equalTo(11))
assertProperties(
props = setOf("id", "time", "status"),
propsNameIfList = "schedules"
)
}
)
}
}
context("관리자 페이지에서 특정 일정의 감사 정보를 조회한다.") {
test("정상 응답") {
val createdSchedule = createDummySchedule(ScheduleFixture.createRequest)
runTest(
token = loginUtil.loginAsAdmin(),
on = {
get("/schedules/${createdSchedule.id}")
},
expect = {
statusCode(HttpStatus.OK.value())
body("data.id", equalTo(createdSchedule.id))
assertProperties(
props = setOf(
"id", "date", "time", "status",
"createdAt", "createdBy", "updatedAt", "updatedBy",
)
)
}
).also {
it.extract().path<String>("data.createdAt") shouldNotBeNull {}
it.extract().path<String>("data.createdBy") shouldNotBeNull {}
it.extract().path<String>("data.createdAt") shouldNotBeNull {}
it.extract().path<String>("data.createdAt") shouldNotBeNull {}
}
}
test("일정이 없으면 실패한다.") {
runTest(
token = loginUtil.loginAsAdmin(),
on = {
get("/schedules/1")
},
expect = {
statusCode(HttpStatus.NOT_FOUND.value())
body(
"code",
equalTo(ScheduleErrorCode.SCHEDULE_NOT_FOUND.errorCode)
)
}
)
}
}
context("일정을 생성한다.") {
lateinit var token: String
beforeTest {
token = loginUtil.loginAsAdmin()
}
test("정상 생성 및 감사 정보 확인") {
/**
* FK 제약조건 해소를 위한 테마 생성 API 호출 ID 획득
*/
val themeId: Long = Given {
contentType(MediaType.APPLICATION_JSON_VALUE)
header("Authorization", "Bearer $token")
body(ThemeFixtureV2.createRequest.copy(name = "theme-${System.currentTimeMillis()}"))
} When {
post("/admin/themes")
} Extract {
path("data.id")
}
/**
* 생성 테스트
*/
runTest(
token = token,
using = {
body(ScheduleFixture.createRequest.copy(themeId = themeId))
},
on = {
post("/schedules")
},
expect = {
statusCode(HttpStatus.OK.value())
body("data.id", notNullValue())
}
).also {
val createdScheduleId: Long = it.extract().path("data.id")
val createdSchedule: ScheduleEntity = scheduleRepository.findByIdOrNull(createdScheduleId)
?: throw AssertionError("Unexpected Exception Occurred.")
createdSchedule.date shouldBe ScheduleFixture.createRequest.date
createdSchedule.time.hour shouldBe ScheduleFixture.createRequest.time.hour
createdSchedule.time.minute shouldBe ScheduleFixture.createRequest.time.minute
createdSchedule.createdAt shouldNotBeNull {}
createdSchedule.createdBy shouldNotBeNull {}
createdSchedule.updatedAt shouldNotBeNull {}
createdSchedule.updatedBy shouldNotBeNull {}
}
}
test("이미 동일한 날짜, 시간, 테마인 일정이 있으면 실패한다.") {
val date = LocalDate.now().plusDays(1)
val time = LocalTime.of(10, 0)
val alreadyCreated: ScheduleEntity = createDummySchedule(
ScheduleFixture.createRequest.copy(
date = date, time = time
)
)
runTest(
token = token,
using = {
body(
ScheduleFixture.createRequest.copy(
date = date, time = time, themeId = alreadyCreated.themeId
)
)
},
on = {
post("/schedules")
},
expect = {
statusCode(HttpStatus.CONFLICT.value())
body("code", equalTo(ScheduleErrorCode.SCHEDULE_ALREADY_EXISTS.errorCode))
}
)
}
test("입력된 날짜 + 시간이 현재 시간 이전이면 실패한다.") {
runTest(
token = token,
using = {
body(
ScheduleFixture.createRequest.copy(
date = LocalDate.now(),
time = LocalTime.now().minusMinutes(1)
)
)
},
on = {
post("/schedules")
},
expect = {
statusCode(HttpStatus.BAD_REQUEST.value())
body("code", equalTo(ScheduleErrorCode.PAST_DATE_TIME.errorCode))
}
)
}
}
context("일정을 수정한다.") {
val updateRequest = ScheduleUpdateRequest(
time = LocalTime.now().plusHours(1),
status = ScheduleStatus.BLOCKED
)
test("정상 수정 및 감사 정보 변경 확인") {
val createdSchedule: ScheduleEntity = createDummySchedule(
ScheduleFixture.createRequest.copy(
date = LocalDate.now().plusDays(1),
time = LocalTime.now().plusMinutes(1),
)
)
val otherAdminToken = loginUtil.login("admin1@admin.com", "admin1", Role.ADMIN)
runTest(
token = otherAdminToken,
using = {
body(updateRequest)
},
on = {
patch("/schedules/${createdSchedule.id}")
},
expect = {
statusCode(HttpStatus.OK.value())
}
).also {
val updatedSchedule = scheduleRepository.findByIdOrNull(createdSchedule.id)!!
updatedSchedule.id shouldBe createdSchedule.id
updatedSchedule.time.hour shouldBe updateRequest.time!!.hour
updatedSchedule.time.minute shouldBe updateRequest.time.minute
updatedSchedule.status shouldBe updateRequest.status
updatedSchedule.updatedBy shouldNotBe createdSchedule.updatedBy
updatedSchedule.updatedAt shouldBeAfter createdSchedule.updatedAt
}
}
test("입력값이 없으면 수정하지 않는다.") {
val createdSchedule: ScheduleEntity = createDummySchedule(ScheduleFixture.createRequest)
runTest(
token = loginUtil.loginAsAdmin(),
using = {
body(ScheduleUpdateRequest())
},
on = {
patch("/schedules/${createdSchedule.id}")
},
expect = {
statusCode(HttpStatus.OK.value())
}
).also {
val updatedSchedule = scheduleRepository.findByIdOrNull(createdSchedule.id)!!
updatedSchedule.id shouldBe createdSchedule.id
updatedSchedule.updatedAt shouldBe createdSchedule.updatedAt
}
}
test("일정이 없으면 실패한다.") {
runTest(
token = loginUtil.loginAsAdmin(),
using = {
body(updateRequest)
},
on = {
patch("/schedules/1")
},
expect = {
statusCode(HttpStatus.NOT_FOUND.value())
body("code", equalTo(ScheduleErrorCode.SCHEDULE_NOT_FOUND.errorCode))
}
)
}
test("입력된 날짜 + 시간이 현재 시간 이전이면 실패한다.") {
val createdSchedule: ScheduleEntity = createDummySchedule(
ScheduleFixture.createRequest.copy(date = LocalDate.now(), time = LocalTime.now().plusMinutes(1))
)
runTest(
token = loginUtil.loginAsAdmin(),
using = {
body(
updateRequest.copy(
time = LocalTime.now().minusMinutes(1)
)
)
},
on = {
patch("/schedules/${createdSchedule.id}")
},
expect = {
statusCode(HttpStatus.BAD_REQUEST.value())
body("code", equalTo(ScheduleErrorCode.PAST_DATE_TIME.errorCode))
}
)
}
}
context("일정을 삭제한다.") {
test("정상 삭제") {
val createdSchedule: ScheduleEntity = createDummySchedule(ScheduleFixture.createRequest)
runTest(
token = loginUtil.loginAsAdmin(),
on = {
delete("/schedules/${createdSchedule.id}")
},
expect = {
statusCode(HttpStatus.NO_CONTENT.value())
}
).also {
scheduleRepository.findByIdOrNull(createdSchedule.id) shouldBe null
}
}
test("예약 중이거나 예약이 완료된 일정이면 실패한다.") {
val createdSchedule: ScheduleEntity = createDummySchedule(ScheduleFixture.createRequest)
/*
* 테스트를 위해 수정 API를 호출하여 상태를 예약 상태로 변경
* 생성 API에서는 일정 생성 AVAILABLE을 기본 상태로 지정하기 때문.
*/
runTest(
token = loginUtil.loginAsAdmin(),
using = {
body(
ScheduleUpdateRequest(
status = ScheduleStatus.RESERVED
)
)
},
on = {
patch("/schedules/${createdSchedule.id}")
},
expect = {
statusCode(HttpStatus.OK.value())
}
)
/**
* 삭제 테스트
*/
runTest(
token = loginUtil.loginAsAdmin(),
on = {
delete("/schedules/${createdSchedule.id}")
},
expect = {
statusCode(HttpStatus.CONFLICT.value())
body("code", equalTo(ScheduleErrorCode.SCHEDULE_IN_USE.errorCode))
}
)
}
}
}
fun createDummySchedule(request: ScheduleCreateRequest): ScheduleEntity {
val token = loginUtil.loginAsAdmin()
val themeId: Long = if (request.themeId > 1L) {
request.themeId
} else {
Given {
contentType(MediaType.APPLICATION_JSON_VALUE)
header("Authorization", "Bearer $token")
body(ThemeFixtureV2.createRequest.copy(name = "theme-${System.currentTimeMillis()}"))
} When {
post("/admin/themes")
} Extract {
path("data.id")
}
}
val createdScheduleId: Long = Given {
contentType(MediaType.APPLICATION_JSON_VALUE)
header("Authorization", "Bearer $token")
body(request.copy(themeId = themeId))
} When {
post("/schedules")
} Extract {
path("data.id")
}
return scheduleRepository.findByIdOrNull(createdScheduleId)
?: throw RuntimeException("unreachable line")
}
}

View File

@ -19,12 +19,12 @@ import roomescape.theme.business.MIN_DURATION
import roomescape.theme.business.MIN_PARTICIPANTS import roomescape.theme.business.MIN_PARTICIPANTS
import roomescape.theme.business.MIN_PRICE import roomescape.theme.business.MIN_PRICE
import roomescape.theme.exception.ThemeErrorCode import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.infrastructure.persistence.v2.Difficulty
import roomescape.theme.infrastructure.persistence.v2.ThemeEntityV2 import roomescape.theme.infrastructure.persistence.v2.ThemeEntityV2
import roomescape.theme.infrastructure.persistence.v2.ThemeRepositoryV2 import roomescape.theme.infrastructure.persistence.v2.ThemeRepositoryV2
import roomescape.theme.web.ThemeCreateRequestV2 import roomescape.theme.web.ThemeCreateRequestV2
import roomescape.theme.web.ThemeUpdateRequest import roomescape.theme.web.ThemeUpdateRequest
import roomescape.util.FunSpecSpringbootTest import roomescape.util.FunSpecSpringbootTest
import roomescape.util.ThemeFixtureV2.createRequest
import roomescape.util.assertProperties import roomescape.util.assertProperties
import roomescape.util.runTest import roomescape.util.runTest
import kotlin.random.Random import kotlin.random.Random
@ -33,19 +33,6 @@ class ThemeApiTest(
private val themeRepository: ThemeRepositoryV2 private val themeRepository: ThemeRepositoryV2
) : FunSpecSpringbootTest() { ) : FunSpecSpringbootTest() {
private val request: ThemeCreateRequestV2 = ThemeCreateRequestV2(
name = "Matilda Green",
description = "constituto",
thumbnailUrl = "https://duckduckgo.com/?q=mediocrem",
difficulty = Difficulty.VERY_EASY,
price = 10000,
minParticipants = 3,
maxParticipants = 5,
availableMinutes = 80,
expectedMinutesFrom = 60,
expectedMinutesTo = 70,
isOpen = true
)
init { init {
context("관리자가 아니면 접근할 수 없다.") { context("관리자가 아니면 접근할 수 없다.") {
@ -64,7 +51,7 @@ class ThemeApiTest(
runTest( runTest(
token = token, token = token,
using = { using = {
body(request) body(createRequest)
}, },
on = { on = {
post("/admin/themes") post("/admin/themes")
@ -97,7 +84,7 @@ class ThemeApiTest(
runTest( runTest(
token = token, token = token,
using = { using = {
body(request) body(createRequest)
}, },
on = { on = {
patch("/admin/themes/1") patch("/admin/themes/1")
@ -119,7 +106,7 @@ class ThemeApiTest(
context("일반 회원도 접근할 수 있다.") { context("일반 회원도 접근할 수 있다.") {
test("테마 조회: GET /v2/themes") { test("테마 조회: GET /v2/themes") {
createDummyTheme(request.copy(name = "test123", isOpen = true)) createDummyTheme(createRequest.copy(name = "test123", isOpen = true))
runTest( runTest(
token = loginUtil.loginAsUser(), token = loginUtil.loginAsUser(),
@ -148,7 +135,7 @@ class ThemeApiTest(
runTest( runTest(
token = token, token = token,
using = { using = {
body(request) body(createRequest)
}, },
on = { on = {
post(apiPath) post(apiPath)
@ -158,11 +145,11 @@ class ThemeApiTest(
body("data.id", notNullValue()) body("data.id", notNullValue())
} }
).also { ).also {
val createdThemeId: String = it.extract().path("data.id") val createdThemeId: Long = it.extract().path("data.id")
val createdTheme: ThemeEntityV2 = themeRepository.findByIdOrNull(createdThemeId.toLong()) val createdTheme: ThemeEntityV2 = themeRepository.findByIdOrNull(createdThemeId)
?: throw AssertionError("Unexpected Exception Occurred.") ?: throw AssertionError("Unexpected Exception Occurred.")
createdTheme.name shouldBe request.name createdTheme.name shouldBe createRequest.name
createdTheme.createdAt shouldNotBeNull {} createdTheme.createdAt shouldNotBeNull {}
createdTheme.createdBy shouldNotBeNull {} createdTheme.createdBy shouldNotBeNull {}
createdTheme.updatedAt shouldNotBeNull {} createdTheme.updatedAt shouldNotBeNull {}
@ -172,12 +159,12 @@ class ThemeApiTest(
test("이미 동일한 이름의 테마가 있으면 실패한다.") { test("이미 동일한 이름의 테마가 있으면 실패한다.") {
val commonName = "test123" val commonName = "test123"
createDummyTheme(request.copy(name = commonName)) createDummyTheme(createRequest.copy(name = commonName))
runTest( runTest(
token = token, token = token,
using = { using = {
body(request.copy(name = commonName)) body(createRequest.copy(name = commonName))
}, },
on = { on = {
post(apiPath) post(apiPath)
@ -193,7 +180,7 @@ class ThemeApiTest(
runTest( runTest(
token = token, token = token,
using = { using = {
body(request.copy(price = (MIN_PRICE - 1))) body(createRequest.copy(price = (MIN_PRICE - 1)))
}, },
on = { on = {
post(apiPath) post(apiPath)
@ -215,7 +202,7 @@ class ThemeApiTest(
runTest( runTest(
token = token, token = token,
using = { using = {
body(request.copy(availableMinutes = (MIN_DURATION - 1).toShort())) body(createRequest.copy(availableMinutes = (MIN_DURATION - 1).toShort()))
}, },
on = { on = {
post(apiPath) post(apiPath)
@ -228,7 +215,7 @@ class ThemeApiTest(
runTest( runTest(
token = token, token = token,
using = { using = {
body(request.copy(expectedMinutesFrom = (MIN_DURATION - 1).toShort())) body(createRequest.copy(expectedMinutesFrom = (MIN_DURATION - 1).toShort()))
}, },
on = { on = {
post(apiPath) post(apiPath)
@ -241,7 +228,7 @@ class ThemeApiTest(
runTest( runTest(
token = token, token = token,
using = { using = {
body(request.copy(expectedMinutesTo = (MIN_DURATION - 1).toShort())) body(createRequest.copy(expectedMinutesTo = (MIN_DURATION - 1).toShort()))
}, },
on = { on = {
post(apiPath) post(apiPath)
@ -256,7 +243,7 @@ class ThemeApiTest(
runTest( runTest(
token = token, token = token,
using = { using = {
body(request.copy(expectedMinutesFrom = 100, expectedMinutesTo = 99)) body(createRequest.copy(expectedMinutesFrom = 100, expectedMinutesTo = 99))
}, },
on = { on = {
post(apiPath) post(apiPath)
@ -273,7 +260,7 @@ class ThemeApiTest(
token = token, token = token,
using = { using = {
body( body(
request.copy( createRequest.copy(
availableMinutes = 100, availableMinutes = 100,
expectedMinutesFrom = 101, expectedMinutesFrom = 101,
expectedMinutesTo = 101 expectedMinutesTo = 101
@ -301,7 +288,7 @@ class ThemeApiTest(
runTest( runTest(
token = token, token = token,
using = { using = {
body(request.copy(minParticipants = (MIN_PARTICIPANTS - 1).toShort())) body(createRequest.copy(minParticipants = (MIN_PARTICIPANTS - 1).toShort()))
}, },
on = { on = {
post(apiPath) post(apiPath)
@ -314,7 +301,7 @@ class ThemeApiTest(
runTest( runTest(
token = token, token = token,
using = { using = {
body(request.copy(maxParticipants = (MIN_PARTICIPANTS - 1).toShort())) body(createRequest.copy(maxParticipants = (MIN_PARTICIPANTS - 1).toShort()))
}, },
on = { on = {
post(apiPath) post(apiPath)
@ -329,7 +316,7 @@ class ThemeApiTest(
runTest( runTest(
token = token, token = token,
using = { using = {
body(request.copy(minParticipants = 10, maxParticipants = 9)) body(createRequest.copy(minParticipants = 10, maxParticipants = 9))
}, },
on = { on = {
post(apiPath) post(apiPath)
@ -345,8 +332,8 @@ class ThemeApiTest(
context("모든 테마를 조회한다.") { context("모든 테마를 조회한다.") {
beforeTest { beforeTest {
createDummyTheme(request.copy(name = "open", isOpen = true)) createDummyTheme(createRequest.copy(name = "open", isOpen = true))
createDummyTheme(request.copy(name = "close", isOpen = false)) createDummyTheme(createRequest.copy(name = "close", isOpen = false))
} }
test("관리자 페이지에서는 비공개 테마까지 포함하여 간단한 정보만 조회된다.") { test("관리자 페이지에서는 비공개 테마까지 포함하여 간단한 정보만 조회된다.") {
@ -389,7 +376,7 @@ class ThemeApiTest(
context("관리자 페이지에서 특정 테마의 상세 정보를 조회한다.") { context("관리자 페이지에서 특정 테마의 상세 정보를 조회한다.") {
test("정상 응답") { test("정상 응답") {
val createdTheme: ThemeEntityV2 = createDummyTheme(request) val createdTheme: ThemeEntityV2 = createDummyTheme(createRequest)
runTest( runTest(
token = loginUtil.loginAsAdmin(), token = loginUtil.loginAsAdmin(),
@ -398,7 +385,7 @@ class ThemeApiTest(
}, },
expect = { expect = {
statusCode(HttpStatus.OK.value()) statusCode(HttpStatus.OK.value())
body("data.id", equalTo(createdTheme.id.toString())) body("data.id", equalTo(createdTheme.id))
assertProperties( assertProperties(
props = setOf( props = setOf(
"id", "name", "description", "thumbnailUrl", "difficulty", "price", "isOpen", "id", "name", "description", "thumbnailUrl", "difficulty", "price", "isOpen",
@ -427,7 +414,7 @@ class ThemeApiTest(
context("테마를 삭제한다.") { context("테마를 삭제한다.") {
test("정상 삭제") { test("정상 삭제") {
val createdTheme = createDummyTheme(request) val createdTheme = createDummyTheme(createRequest)
runTest( runTest(
token = loginUtil.loginAsAdmin(), token = loginUtil.loginAsAdmin(),
@ -465,7 +452,7 @@ class ThemeApiTest(
beforeTest { beforeTest {
token = loginUtil.loginAsAdmin() token = loginUtil.loginAsAdmin()
createdTheme = createDummyTheme(request.copy(name = "theme-${Random.nextInt()}")) createdTheme = createDummyTheme(createRequest.copy(name = "theme-${Random.nextInt()}"))
apiPath = "/admin/themes/${createdTheme.id}" apiPath = "/admin/themes/${createdTheme.id}"
} }
@ -493,6 +480,26 @@ class ThemeApiTest(
} }
} }
test("입력값이 없으면 수정하지 않는다.") {
runTest(
token = loginUtil.loginAsAdmin(),
using = {
body(ThemeUpdateRequest())
},
on = {
patch(apiPath)
},
expect = {
statusCode(HttpStatus.OK.value())
}
).also {
val updatedTheme = themeRepository.findByIdOrNull(createdTheme.id)!!
updatedTheme.id shouldBe createdTheme.id
updatedTheme.updatedAt shouldBe createdTheme.updatedAt
}
}
test("테마가 없으면 실패한다.") { test("테마가 없으면 실패한다.") {
runTest( runTest(
token = token, token = token,
@ -665,7 +672,7 @@ class ThemeApiTest(
} }
fun createDummyTheme(request: ThemeCreateRequestV2): ThemeEntityV2 { fun createDummyTheme(request: ThemeCreateRequestV2): ThemeEntityV2 {
val createdThemeId: String = Given { val createdThemeId: Long = Given {
contentType(MediaType.APPLICATION_JSON_VALUE) contentType(MediaType.APPLICATION_JSON_VALUE)
header("Authorization", "Bearer ${loginUtil.loginAsAdmin()}") header("Authorization", "Bearer ${loginUtil.loginAsAdmin()}")
body(request) body(request)
@ -675,7 +682,7 @@ class ThemeApiTest(
path("data.id") path("data.id")
} }
return themeRepository.findByIdOrNull(createdThemeId.toLong()) return themeRepository.findByIdOrNull(createdThemeId)
?: throw RuntimeException("unreachable line") ?: throw RuntimeException("unreachable line")
} }
} }

View File

@ -0,0 +1,51 @@
package roomescape.util
import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.Role
import roomescape.schedule.web.ScheduleCreateRequest
import roomescape.theme.infrastructure.persistence.v2.Difficulty
import roomescape.theme.web.ThemeCreateRequestV2
import java.time.LocalDate
import java.time.LocalTime
object MemberFixtureV2 {
val admin: MemberEntity = MemberEntity(
_id = 9304,
name = "ADMIN",
email = "admin@example.com",
password = "adminPassword",
role = Role.ADMIN
)
val user: MemberEntity = MemberEntity(
_id = 9305,
name = "USER",
email = "user@example.com",
password = "userPassword",
role = Role.MEMBER
)
}
object ThemeFixtureV2 {
val createRequest: ThemeCreateRequestV2 = ThemeCreateRequestV2(
name = "Matilda Green",
description = "constituto",
thumbnailUrl = "https://duckduckgo.com/?q=mediocrem",
difficulty = Difficulty.VERY_EASY,
price = 10000,
minParticipants = 3,
maxParticipants = 5,
availableMinutes = 80,
expectedMinutesFrom = 60,
expectedMinutesTo = 70,
isOpen = true
)
}
object ScheduleFixture {
val createRequest: ScheduleCreateRequest = ScheduleCreateRequest(
date = LocalDate.now().plusDays(1),
time = LocalTime.now(),
themeId = 1L
)
}

View File

@ -43,11 +43,11 @@ class LoginUtil(
} }
fun loginAsAdmin(): String { fun loginAsAdmin(): String {
return login(MemberFixture.admin().email, MemberFixture.admin().password, Role.ADMIN) return login(MemberFixtureV2.admin.email, MemberFixtureV2.admin.password, Role.ADMIN)
} }
fun loginAsUser(): String { fun loginAsUser(): String {
return login(MemberFixture.user().email, MemberFixture.user().password) return login(MemberFixtureV2.user.email, MemberFixtureV2.user.password)
} }
} }