Skip to content

Commit a333830

Browse files
authored
Merge pull request #312 from DevKor-github/codexd/schedule-start-lock
Add schedule start lifecycle locking
2 parents 97f91ab + 0c433ba commit a333830

22 files changed

Lines changed: 941 additions & 33 deletions

docs/schedule-start-api.md

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
# Schedule Start API
2+
3+
Frontend integration guide for server-authoritative preparation start state.
4+
5+
## Summary
6+
7+
The backend now uses `startedAt` as the source of truth for whether preparation has started. Flutter should stop treating client-side `isStarted` as authoritative.
8+
9+
Preparation becomes frozen when the user starts a schedule:
10+
11+
- Default preparation is a mutable template.
12+
- Started schedule preparation is a schedule-specific snapshot.
13+
- After `startedAt` is set, schedule preparation reads come from the frozen snapshot, not from the user's default preparation.
14+
15+
## Authentication
16+
17+
All endpoints require the current OnTime access token.
18+
19+
```http
20+
Authorization: Bearer {accessToken}
21+
Content-Type: application/json
22+
```
23+
24+
## Schedule Response Field
25+
26+
Schedule responses now include nullable `startedAt`.
27+
28+
```json
29+
{
30+
"scheduleId": "3fa85f64-5717-4562-b3fc-2c963f66afe5",
31+
"place": {
32+
"placeId": "70d460da-6a82-4c57-a285-567cdeda5670",
33+
"placeName": "Home"
34+
},
35+
"scheduleName": "Party",
36+
"moveTime": 20,
37+
"scheduleTime": "2026-05-13T19:30:00",
38+
"scheduleSpareTime": 20,
39+
"scheduleNote": "Write a message.",
40+
"latenessTime": -1,
41+
"doneStatus": "NOT_ENDED",
42+
"startedAt": "2026-05-13T10:15:30Z",
43+
"finishedAt": null
44+
}
45+
```
46+
47+
Fields:
48+
49+
| Field | Type | Notes |
50+
| --- | --- | --- |
51+
| `startedAt` | ISO-8601 UTC datetime or `null` | `null` means preparation has not explicitly started. Non-null means the schedule is locked for editing. |
52+
| `finishedAt` | ISO-8601 UTC datetime or `null` | Non-null means the schedule was explicitly finished by the finish endpoint. |
53+
| `doneStatus` | enum | `NOT_ENDED`, `NORMAL`, `LATE`, or `ABNORMAL`. Finished schedules cannot be edited or deleted. |
54+
| `latenessTime` | integer or `null` | Completion result. `-1` is legacy/unended data; new finish calls use `0` for normal or positive minutes for late. |
55+
56+
Frontend rule:
57+
58+
```text
59+
canEditSchedule = doneStatus == "NOT_ENDED" && startedAt == null
60+
canDeleteSchedule = doneStatus == "NOT_ENDED"
61+
```
62+
63+
## Start Preparation
64+
65+
Call this endpoint when the user taps "Start preparation".
66+
67+
```http
68+
POST /schedules/{scheduleId}/start
69+
```
70+
71+
Request body: none.
72+
73+
Behavior:
74+
75+
- If the schedule has not started, backend sets `startedAt` to server time.
76+
- If the schedule still uses default preparation, backend copies the current default preparation into schedule-specific preparation rows.
77+
- If the schedule already started, backend returns success without changing `startedAt` or replacing the frozen preparation snapshot.
78+
- If the schedule is finished, backend returns `409 SCHEDULE_ALREADY_FINISHED`.
79+
80+
Success response:
81+
82+
```json
83+
{
84+
"status": "success",
85+
"code": 200,
86+
"message": "OK",
87+
"data": {
88+
"schedule": {
89+
"scheduleId": "3fa85f64-5717-4562-b3fc-2c963f66afe5",
90+
"place": {
91+
"placeId": "70d460da-6a82-4c57-a285-567cdeda5670",
92+
"placeName": "Home"
93+
},
94+
"scheduleName": "Party",
95+
"moveTime": 20,
96+
"scheduleTime": "2026-05-13T19:30:00",
97+
"scheduleSpareTime": 20,
98+
"scheduleNote": "Write a message.",
99+
"latenessTime": -1,
100+
"doneStatus": "NOT_ENDED",
101+
"startedAt": "2026-05-13T10:15:30Z",
102+
"finishedAt": null
103+
},
104+
"preparations": [
105+
{
106+
"preparationId": "123e4567-e89b-12d3-a456-426614174011",
107+
"preparationName": "Wash up",
108+
"preparationTime": 10,
109+
"nextPreparationId": "123e4567-e89b-12d3-a456-426614174012"
110+
},
111+
{
112+
"preparationId": "123e4567-e89b-12d3-a456-426614174012",
113+
"preparationName": "Get dressed",
114+
"preparationTime": 15,
115+
"nextPreparationId": null
116+
}
117+
]
118+
}
119+
}
120+
```
121+
122+
Frontend behavior:
123+
124+
- After success, update local schedule state from `data.schedule`.
125+
- Use `data.preparations` as the running preparation steps.
126+
- Hide or disable schedule edit actions because `startedAt != null`.
127+
- It is safe to retry this request; the endpoint is idempotent.
128+
129+
## Update Schedule
130+
131+
Existing endpoint:
132+
133+
```http
134+
PUT /schedules/{scheduleId}
135+
```
136+
137+
New server-side guard:
138+
139+
- Allowed only when `doneStatus == "NOT_ENDED"` and `startedAt == null`.
140+
- Backend ignores incoming `isStarted`; do not send or depend on it for locking.
141+
142+
Started schedule error:
143+
144+
```json
145+
{
146+
"status": "error",
147+
"code": "SCHEDULE_ALREADY_STARTED",
148+
"message": "Started schedules cannot be edited.",
149+
"data": null
150+
}
151+
```
152+
153+
Finished schedule error:
154+
155+
```json
156+
{
157+
"status": "error",
158+
"code": "SCHEDULE_ALREADY_FINISHED",
159+
"message": "Finished schedules cannot be edited.",
160+
"data": null
161+
}
162+
```
163+
164+
## Update Schedule-Specific Preparation
165+
166+
Existing endpoints:
167+
168+
```http
169+
POST /schedules/{scheduleId}/preparations
170+
PUT /schedules/{scheduleId}/preparations
171+
```
172+
173+
New server-side guard:
174+
175+
- Allowed only when `doneStatus == "NOT_ENDED"` and `startedAt == null`.
176+
- Returns `409 SCHEDULE_ALREADY_STARTED` after preparation has started.
177+
- Returns `409 SCHEDULE_ALREADY_FINISHED` after the schedule is finished.
178+
179+
Reason:
180+
181+
- Editing steps or durations after start would invalidate the active preparation flow.
182+
183+
## Delete Schedule
184+
185+
Existing endpoint:
186+
187+
```http
188+
DELETE /schedules/{scheduleId}
189+
```
190+
191+
Rule:
192+
193+
- Allowed when `doneStatus == "NOT_ENDED"`.
194+
- Started but unfinished schedules can be deleted.
195+
- Finished schedules cannot be deleted.
196+
197+
Finished schedule error:
198+
199+
```json
200+
{
201+
"status": "error",
202+
"code": "SCHEDULE_ALREADY_FINISHED",
203+
"message": "Finished schedules cannot be edited.",
204+
"data": null
205+
}
206+
```
207+
208+
## Default Preparation Updates
209+
210+
Existing user-default preparation update remains allowed.
211+
212+
```http
213+
PUT /preparations
214+
```
215+
216+
Frontend behavior:
217+
218+
- Users may edit default preparation in settings even if they have already started a schedule.
219+
- This updates only the default template.
220+
- It does not change any started schedule's frozen preparation snapshot.
221+
- Future or unstarted schedules that still use default preparation may resolve the updated default template.
222+
223+
## Finish Preparation
224+
225+
Existing endpoint:
226+
227+
```http
228+
PUT /schedules/{scheduleId}/finish
229+
```
230+
231+
New server-side guard:
232+
233+
- Allowed only after explicit start, when `startedAt != null`.
234+
- Unstarted missed schedules remain `NOT_ENDED` and do not count toward punctuality score.
235+
- Finished schedules still return `409 SCHEDULE_ALREADY_FINISHED`.
236+
237+
Unstarted schedule error:
238+
239+
```json
240+
{
241+
"status": "error",
242+
"code": "SCHEDULE_NOT_STARTED",
243+
"message": "Schedules must be started before they can be finished.",
244+
"data": null
245+
}
246+
```
247+
248+
Punctuality scoring rule:
249+
250+
```text
251+
includedInPunctualityScore =
252+
startedAt != null
253+
&& finishedAt != null
254+
&& doneStatus in (NORMAL, LATE)
255+
```
256+
257+
So a schedule with:
258+
259+
```text
260+
doneStatus == NOT_ENDED
261+
startedAt == null
262+
scheduleTime < now
263+
```
264+
265+
is a missed/unstarted schedule. It can be deleted, but it is not auto-finished and does not affect punctuality score.
266+
267+
`ABNORMAL` is also excluded from punctuality score. It is reserved for abnormal completion states and should not improve or worsen the score.
268+
269+
## Alarm Window Response
270+
271+
`GET /schedules/alarm-window` also includes `startedAt` and `finishedAt` for each schedule.
272+
273+
```json
274+
{
275+
"scheduleId": "3fa85f64-5717-4562-b3fc-2c963f66afe5",
276+
"scheduleName": "Morning meeting",
277+
"scheduleTime": "2026-05-13T09:30:00",
278+
"moveTime": 20,
279+
"scheduleSpareTime": 10,
280+
"doneStatus": "NOT_ENDED",
281+
"startedAt": "2026-05-13T08:15:30Z",
282+
"finishedAt": null,
283+
"preparationStartTime": "2026-05-13T08:40:00",
284+
"defaultAlarmTime": "2026-05-13T08:30:00",
285+
"preparations": []
286+
}
287+
```
288+
289+
## Migration Notes For Flutter
290+
291+
Recommended client changes:
292+
293+
- Read `startedAt` from schedule responses.
294+
- Read `finishedAt` when displaying explicit completion state.
295+
- Treat `startedAt != null` as "preparation has started".
296+
- Stop treating `isStarted` as authoritative.
297+
- Call `POST /schedules/{id}/start` only when the user explicitly taps "Start preparation".
298+
- On start success, replace local running preparation state with `data.preparations`.
299+
- Hide or disable schedule/preparation edit actions when `startedAt != null`.
300+
- Hide or disable edit/delete actions when `doneStatus != "NOT_ENDED"`, except delete is still allowed for started schedules if `doneStatus == "NOT_ENDED"`.
301+
- Do not call finish for schedules that never successfully started.

ontime-back/src/main/java/devkor/ontime_back/controller/ScheduleController.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,24 @@ public ResponseEntity<ApiResponseForm<ScheduleDto>> getScheduleById(
145145
return ResponseEntity.status(HttpStatus.OK).body(ApiResponseForm.success(schedule));
146146
}
147147

148+
@Operation(summary = "준비 시작")
149+
@ApiResponses(value = {
150+
@ApiResponse(responseCode = "200", description = "준비 시작 성공"),
151+
@ApiResponse(responseCode = "409", description = "이미 종료된 약속")
152+
})
153+
@PostMapping("/{scheduleId}/start")
154+
public ResponseEntity<ApiResponseForm<StartScheduleResponseDto>> startSchedule(
155+
HttpServletRequest request,
156+
@Parameter(description = "준비를 시작할 스케줄 ID (UUID 형식)",
157+
required = true,
158+
example = "3fa85f64-5717-4562-b3fc-2c963f66afe5")
159+
@PathVariable UUID scheduleId) {
160+
161+
Long userId = userAuthService.getUserIdFromToken(request);
162+
StartScheduleResponseDto response = scheduleService.startSchedule(userId, scheduleId);
163+
return ResponseEntity.status(HttpStatus.OK).body(ApiResponseForm.success(response));
164+
}
165+
148166
// 약속 삭제
149167
@Operation(summary = "사용자 일정 삭제",
150168
parameters = {

ontime-back/src/main/java/devkor/ontime_back/dto/AlarmWindowScheduleDto.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import lombok.AllArgsConstructor;
55
import lombok.Builder;
66
import lombok.Getter;
7+
import java.time.Instant;
78
import java.time.LocalDateTime;
89
import java.util.List;
910
import java.util.UUID;
@@ -19,6 +20,8 @@ public class AlarmWindowScheduleDto {
1920
private Integer moveTime;
2021
private Integer scheduleSpareTime;
2122
private DoneStatus doneStatus;
23+
private Instant startedAt;
24+
private Instant finishedAt;
2225
private LocalDateTime preparationStartTime;
2326
private LocalDateTime defaultAlarmTime;
2427
private List<PreparationDto> preparations;

ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleAddDto.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ public Schedule toEntity(User user, Place place) {
5656
.scheduleTime(this.scheduleTime)
5757
.isChange(false)
5858
.isStarted(false)
59+
.startedAt(null)
60+
.finishedAt(null)
5961
.scheduleSpareTime(this.scheduleSpareTime)
6062
.latenessTime(-1)
6163
.scheduleNote(this.scheduleNote)

ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleDto.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import devkor.ontime_back.entity.User;
66
import jakarta.persistence.*;
77
import lombok.*;
8-
import java.sql.Time;
8+
import java.time.Instant;
99
import java.time.LocalDateTime;
1010
import java.util.UUID;
1111

@@ -22,4 +22,20 @@ public class ScheduleDto {
2222
private String scheduleNote;
2323
private Integer latenessTime;
2424
private DoneStatus doneStatus;
25+
private Instant startedAt;
26+
private Instant finishedAt;
27+
28+
public ScheduleDto(UUID scheduleId, PlaceDto place, String scheduleName, Integer moveTime,
29+
LocalDateTime scheduleTime, Integer scheduleSpareTime, String scheduleNote,
30+
Integer latenessTime, DoneStatus doneStatus) {
31+
this(scheduleId, place, scheduleName, moveTime, scheduleTime, scheduleSpareTime,
32+
scheduleNote, latenessTime, doneStatus, null, null);
33+
}
34+
35+
public ScheduleDto(UUID scheduleId, PlaceDto place, String scheduleName, Integer moveTime,
36+
LocalDateTime scheduleTime, Integer scheduleSpareTime, String scheduleNote,
37+
Integer latenessTime, DoneStatus doneStatus, Instant startedAt) {
38+
this(scheduleId, place, scheduleName, moveTime, scheduleTime, scheduleSpareTime,
39+
scheduleNote, latenessTime, doneStatus, startedAt, null);
40+
}
2541
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package devkor.ontime_back.dto;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Getter;
5+
6+
import java.util.List;
7+
8+
@Getter
9+
@AllArgsConstructor
10+
public class StartScheduleResponseDto {
11+
private ScheduleDto schedule;
12+
private List<PreparationDto> preparations;
13+
}

0 commit comments

Comments
 (0)