diff --git a/docs/preparation-templates-frontend.md b/docs/preparation-templates-frontend.md new file mode 100644 index 0000000..34deb53 --- /dev/null +++ b/docs/preparation-templates-frontend.md @@ -0,0 +1,248 @@ +# Preparation Templates Frontend Contract + +## Concepts +Schedules now have one preparation source: + +- `DEFAULT`: uses the user's fixed default preparation from `GET /users/preparations`. +- `TEMPLATE`: uses a named preparation template from `GET /preparation-templates`. +- `CUSTOM`: uses schedule-specific preparation steps. + +Started schedules are frozen. Use `preparationFrozen` from schedule responses; it is `true` when `startedAt` is present. Frozen schedules still show their original `preparationMode`, but their preparation steps come from the schedule snapshot. + +## Ordered Preparation Shape +New APIs use ordered steps: + +```json +{ + "preparationId": "3fa85f64-5717-4562-b3fc-2c963f66afe5", + "preparationName": "Shower", + "preparationTime": 15, + "orderIndex": 0 +} +``` + +Rules: +- Client provides UUIDs for templates and preparation steps. +- `orderIndex` is zero-based and contiguous. +- Arrays may be sent in any order; backend stores and returns by `orderIndex`. +- Each list must contain 1-50 steps. +- Each `preparationTime` must be 1-1440 minutes. +- Total preparation time per list must be at most 1440 minutes. +- Step names are trimmed; duplicate step names are allowed. +- Step IDs must not be reused across other templates, other schedules, or the fixed default. Reusing the same step ID within the same resource update is allowed. + +## Named Template APIs +The fixed default preparation is not included in these endpoints. + +### List Active Templates +`GET /preparation-templates` + +Returns active named templates with full steps. Deleted templates are excluded. + +```json +{ + "status": "success", + "data": [ + { + "templateId": "11111111-1111-1111-1111-111111111111", + "templateName": "Work", + "createdAt": "2026-05-14T02:10:00Z", + "updatedAt": "2026-05-14T02:10:00Z", + "deletedAt": null, + "preparations": [] + } + ] +} +``` + +### Get Template Detail +`GET /preparation-templates/{templateId}` + +Works for active and soft-deleted templates owned by the user. Detail includes `deletedAt`. + +### Create Template +`POST /preparation-templates` + +```json +{ + "templateId": "11111111-1111-1111-1111-111111111111", + "templateName": "Work", + "preparations": [ + { + "preparationId": "22222222-2222-2222-2222-222222222222", + "preparationName": "Pack laptop", + "preparationTime": 5, + "orderIndex": 0 + } + ] +} +``` + +Active template names are unique per user after trimming and case-insensitive normalization. Deleted template names can be reused, but deleted template IDs cannot. + +### Update Template +`PUT /preparation-templates/{templateId}` + +Full replace of name and steps. Deleted templates cannot be updated. + +### Delete Template +`DELETE /preparation-templates/{templateId}` + +Soft-deletes the template. Existing schedules that already use the template keep using it. New schedule create/update cannot select it. Repeated delete succeeds. + +## Schedule Create +`POST /schedules` + +Preparation source is inferred: + +- Omit both `preparationTemplateId` and `customPreparations`: creates `DEFAULT`. +- Send only `preparationTemplateId`: creates `TEMPLATE`. +- Send only `customPreparations`: creates `CUSTOM`. +- Send both: rejected. + +Template mode: + +```json +{ + "scheduleId": "33333333-3333-3333-3333-333333333333", + "placeId": "44444444-4444-4444-4444-444444444444", + "placeName": "Office", + "scheduleName": "Morning meeting", + "moveTime": 20, + "scheduleTime": "2026-06-01T09:30:00", + "scheduleSpareTime": 10, + "scheduleNote": "Bring laptop", + "preparationTemplateId": "11111111-1111-1111-1111-111111111111" +} +``` + +Custom mode: + +```json +{ + "scheduleId": "33333333-3333-3333-3333-333333333333", + "placeId": "44444444-4444-4444-4444-444444444444", + "placeName": "Office", + "scheduleName": "Morning meeting", + "moveTime": 20, + "scheduleTime": "2026-06-01T09:30:00", + "customPreparations": [ + { + "preparationId": "55555555-5555-5555-5555-555555555555", + "preparationName": "Pack laptop", + "preparationTime": 5, + "orderIndex": 0 + } + ] +} +``` + +## Schedule Update +`PUT /schedules/{scheduleId}` + +If `preparationMode` is omitted, the current preparation source is preserved. This includes schedules linked to soft-deleted templates. + +To change source: + +```json +{ + "placeId": "44444444-4444-4444-4444-444444444444", + "placeName": "Office", + "scheduleName": "Morning meeting", + "moveTime": 20, + "scheduleTime": "2026-06-01T09:30:00", + "preparationMode": "DEFAULT" +} +``` + +```json +{ + "placeId": "44444444-4444-4444-4444-444444444444", + "placeName": "Office", + "scheduleName": "Morning meeting", + "moveTime": 20, + "scheduleTime": "2026-06-01T09:30:00", + "preparationMode": "TEMPLATE", + "preparationTemplateId": "11111111-1111-1111-1111-111111111111" +} +``` + +```json +{ + "placeId": "44444444-4444-4444-4444-444444444444", + "placeName": "Office", + "scheduleName": "Morning meeting", + "moveTime": 20, + "scheduleTime": "2026-06-01T09:30:00", + "preparationMode": "CUSTOM", + "customPreparations": [ + { + "preparationId": "55555555-5555-5555-5555-555555555555", + "preparationName": "Pack laptop", + "preparationTime": 5, + "orderIndex": 0 + } + ] +} +``` + +Mixed mode payloads are rejected: +- `DEFAULT` with template ID or custom list. +- `TEMPLATE` without template ID. +- `TEMPLATE` with custom list. +- `CUSTOM` without full custom list. +- `CUSTOM` with template ID. + +Started schedules cannot be edited. + +## Schedule Responses +Normal schedule list/detail responses include metadata, not full step lists: + +```json +{ + "scheduleId": "33333333-3333-3333-3333-333333333333", + "scheduleName": "Morning meeting", + "startedAt": null, + "finishedAt": null, + "preparationMode": "TEMPLATE", + "preparationTemplateId": "11111111-1111-1111-1111-111111111111", + "preparationTemplateName": "Work", + "preparationTemplateDeleted": false, + "preparationFrozen": false +} +``` + +For default and custom schedules, `preparationTemplateId` and `preparationTemplateName` are `null`. + +For schedules linked to a deleted template: + +```json +{ + "preparationMode": "TEMPLATE", + "preparationTemplateId": "11111111-1111-1111-1111-111111111111", + "preparationTemplateName": "Work", + "preparationTemplateDeleted": true +} +``` + +## Existing Compatibility Endpoints +These remain linked-list shaped: + +- `GET /users/preparations` +- `PUT /users/preparations` +- `GET /schedules/{scheduleId}/preparations` +- `POST /schedules/{scheduleId}/preparations` +- `PUT /schedules/{scheduleId}/preparations` + +`POST/PUT /schedules/{scheduleId}/preparations` now means "make this schedule CUSTOM" and clears any template link. The request still uses `nextPreparationId`. + +## Alarm Window +`GET /schedules/alarm-window` continues to include full `preparations`, and now also includes the same preparation metadata fields as normal schedule responses. + +## Error Codes To Handle +- `PREPARATION_TEMPLATE_NOT_FOUND`: missing or cross-user template. +- `PREPARATION_TEMPLATE_NAME_DUPLICATE`: active template name already exists. +- `PREPARATION_TEMPLATE_LIMIT_EXCEEDED`: user already has 20 active named templates. +- `PREPARATION_TEMPLATE_DELETED`: user tried to select or update an owned deleted template. +- `PREPARATION_STEP_ID_CONFLICT`: provided step ID belongs to another preparation resource. +- `INVALID_INPUT`: malformed mode combination, ordering, list size, duration, or linked-list payload. diff --git a/docs/preparation-templates-transition-issue.md b/docs/preparation-templates-transition-issue.md new file mode 100644 index 0000000..d818c5d --- /dev/null +++ b/docs/preparation-templates-transition-issue.md @@ -0,0 +1,140 @@ +# Clean Transition Plan For Preparation Templates And Schedule Preparation Modes + +## Context +The backend is adding multiple named preparation templates while keeping the existing fixed default preparation flow. This transition needs to be intentionally staged because existing clients still use linked-list preparation payloads (`nextPreparationId`) and the old `isChange` concept, while new clients should use explicit schedule preparation modes and ordered preparation steps. + +This issue tracks the clean transition work after the initial implementation lands. + +## Product Model To Preserve +- Every user has one fixed default preparation set. +- The fixed default has no custom name and is managed through the existing `/users/preparations` compatibility endpoints. +- Users can create up to 20 active named preparation templates. +- Schedules can use exactly one preparation source: `DEFAULT`, `TEMPLATE`, or `CUSTOM`. +- Named templates are soft-deleted with `deletedAt`. +- Soft-deleted templates are hidden from future selection but remain resolvable for schedules that already reference them. +- Started schedules are frozen. Their response keeps the original preparation source metadata, but steps are read from schedule snapshot rows. + +## New Frontend Contract To Roll Out +Template APIs: +- `GET /preparation-templates`: active named templates only, excluding fixed default, with full ordered steps. +- `GET /preparation-templates/{templateId}`: owner-only direct lookup, including deleted templates and `deletedAt`. +- `POST /preparation-templates`: create a named template with client-provided template and step UUIDs. +- `PUT /preparation-templates/{templateId}`: full replace of name and steps; deleted templates are immutable. +- `DELETE /preparation-templates/{templateId}`: soft delete; repeated delete succeeds. + +Ordered step shape: +```json +{ + "preparationId": "uuid", + "preparationName": "Pack laptop", + "preparationTime": 5, + "orderIndex": 0 +} +``` + +Schedule create modes: +- Neither `preparationTemplateId` nor `customPreparations`: create `DEFAULT`. +- Only `preparationTemplateId`: create `TEMPLATE`. +- Only `customPreparations`: create `CUSTOM`. +- Both fields: reject. + +Schedule update modes: +- Omit `preparationMode`: keep current source unchanged. +- `DEFAULT`: no template ID, no custom list. +- `TEMPLATE`: requires active owned template ID, no custom list. +- `CUSTOM`: requires full custom list, no template ID. + +Schedule response metadata: +```json +{ + "preparationMode": "TEMPLATE", + "preparationTemplateId": "uuid-or-null", + "preparationTemplateName": "Work", + "preparationTemplateDeleted": false, + "preparationFrozen": false, + "startedAt": null, + "finishedAt": null +} +``` + +## Compatibility Endpoints To Keep During Transition +Keep these linked-list shaped: +- `GET /users/preparations` +- `PUT /users/preparations` +- `GET /schedules/{scheduleId}/preparations` +- `POST /schedules/{scheduleId}/preparations` +- `PUT /schedules/{scheduleId}/preparations` + +Compatibility behavior: +- Existing request/response shape uses `nextPreparationId`. +- Backend stores/maintains `orderIndex` internally. +- Backend synthesizes `nextPreparationId` from order on compatibility reads. +- Old schedule-preparation POST/PUT maps the schedule to `CUSTOM`, clears any template link, and replaces schedule-specific rows. + +## Migration And Rollout Checklist +Phase 1: Backend compatibility release +- [ ] Add `order_index` to `preparation_user` and `preparation_schedule`. +- [ ] Backfill existing rows. +- [ ] Keep `next_preparation_id` during transition. +- [ ] Add `preparation_mode` to `schedule`. +- [ ] Add nullable `preparation_template_id` to `schedule`. +- [ ] Migrate old `is_change = true` schedules to `CUSTOM`. +- [ ] Migrate old `is_change = false` schedules to `DEFAULT`. +- [ ] Document the compromise that historical started default snapshots with `is_change = true` become `CUSTOM` because source intent cannot be reliably recovered. +- [ ] Add template tables and endpoints. +- [ ] Add schedule response metadata. +- [ ] Keep old endpoints working. + +Phase 2: Frontend adoption +- [ ] Update template picker to show local fixed default option plus named templates from `/preparation-templates`. +- [ ] Treat missing `preparationTemplateId` plus no custom list as fixed default on create. +- [ ] Use `preparationMode` for schedule update source changes. +- [ ] Use ordered `customPreparations` for new custom schedule create/update. +- [ ] Continue using old linked-list endpoints only where necessary. +- [ ] Show deleted linked templates as disabled/unavailable when `preparationTemplateDeleted = true`. +- [ ] Prevent selecting deleted templates from the picker. +- [ ] Generate new step UUIDs when copying a template into custom schedule steps. +- [ ] Respect `preparationFrozen = true` by disabling preparation edits on started schedules. + +Phase 3: Monitoring and validation +- [ ] Verify old app versions can still onboard and update `/users/preparations`. +- [ ] Verify old app versions can still create schedule-specific preparations via `/schedules/{id}/preparations`. +- [ ] Verify new app can create `DEFAULT`, `TEMPLATE`, and `CUSTOM` schedules. +- [ ] Verify template updates refresh not-started, not-finished template-mode schedules. +- [ ] Verify default preparation updates refresh not-started, not-finished default-mode schedules. +- [ ] Verify started schedules keep frozen snapshot steps. +- [ ] Verify deleted templates remain visible through schedule metadata and direct detail lookup. +- [ ] Verify account deletion removes named templates and template steps. +- [ ] Verify privacy/account deletion docs mention named templates. + +Phase 4: Cleanup after client migration +- [ ] Stop documenting `isChange` for new clients. +- [ ] Remove client use of linked-list `nextPreparationId` from new app code. +- [ ] Add a versioned ordered preparation read endpoint if frontend needs ordered schedule/default reads without linked-list compatibility. +- [ ] Once old clients are no longer supported, remove or fully ignore `is_change`. +- [ ] Once old linked-list endpoints are retired, remove `next_preparation_id` from `preparation_user` and `preparation_schedule`. +- [ ] Remove compatibility synthesis code for `nextPreparationId`. +- [ ] Simplify repository queries to rely only on `order_index`. + +## Edge Cases To Test Explicitly +- Creating a template with duplicate active name after trim/case normalization is rejected. +- Creating a template after deleting another template with the same name succeeds. +- Creating a template with a deleted template's same ID is rejected. +- Deleting a template twice succeeds. +- Updating a deleted template is rejected. +- Selecting an owned deleted template for schedule create/update returns a deleted-specific error. +- Selecting another user's template behaves as not found. +- Schedule detail edit with omitted `preparationMode` preserves a deleted template reference. +- Schedule linked to deleted template can switch to default, active template, or custom. +- Custom schedule update can reuse its own step IDs and reorder them. +- Custom schedule update cannot use step IDs from templates, fixed default, or another schedule. +- Template update can reuse its own step IDs and reorder them. +- Fixed default update can reuse its own step IDs. +- Malformed linked-list payloads are rejected: cycles, multiple heads, disconnected nodes, unknown next IDs, duplicate IDs. +- Malformed ordered payloads are rejected: duplicate/gapped indexes, duplicate IDs, empty list, more than 50 steps, total duration over 1440. +- Equal-time step content changes still refresh notifications without leaving duplicate scheduled tasks. + +## Open Implementation Notes +- Confirm whether DB-level active-name uniqueness can be enforced cleanly in the deployed MySQL version; service-level validation is still required. +- Confirm native alarm payload refresh behavior when notification time does not change but step names/order do. +- Keep `docs/preparation-templates-frontend.md` aligned with actual endpoint behavior. diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/PreparationTemplateController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/PreparationTemplateController.java new file mode 100644 index 0000000..e1e36fb --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/PreparationTemplateController.java @@ -0,0 +1,68 @@ +package devkor.ontime_back.controller; + +import devkor.ontime_back.dto.PreparationTemplateRequestDto; +import devkor.ontime_back.dto.PreparationTemplateResponseDto; +import devkor.ontime_back.dto.PreparationTemplateUpdateDto; +import devkor.ontime_back.response.ApiResponseForm; +import devkor.ontime_back.service.PreparationTemplateService; +import devkor.ontime_back.service.UserAuthService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/preparation-templates") +@RequiredArgsConstructor +@Validated +public class PreparationTemplateController { + private final PreparationTemplateService preparationTemplateService; + private final UserAuthService userAuthService; + + @GetMapping + public ResponseEntity>> listTemplates(HttpServletRequest request) { + Long userId = userAuthService.getUserIdFromToken(request); + return ResponseEntity.ok(ApiResponseForm.success(preparationTemplateService.listTemplates(userId))); + } + + @GetMapping("/{templateId}") + public ResponseEntity> getTemplate( + HttpServletRequest request, + @PathVariable UUID templateId) { + Long userId = userAuthService.getUserIdFromToken(request); + return ResponseEntity.ok(ApiResponseForm.success(preparationTemplateService.getTemplate(userId, templateId))); + } + + @PostMapping + public ResponseEntity> createTemplate( + HttpServletRequest request, + @Valid @RequestBody PreparationTemplateRequestDto requestDto) { + Long userId = userAuthService.getUserIdFromToken(request); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponseForm.success(preparationTemplateService.createTemplate(userId, requestDto))); + } + + @PutMapping("/{templateId}") + public ResponseEntity> updateTemplate( + HttpServletRequest request, + @PathVariable UUID templateId, + @Valid @RequestBody PreparationTemplateUpdateDto requestDto) { + Long userId = userAuthService.getUserIdFromToken(request); + return ResponseEntity.ok(ApiResponseForm.success(preparationTemplateService.updateTemplate(userId, templateId, requestDto))); + } + + @DeleteMapping("/{templateId}") + public ResponseEntity> deleteTemplate( + HttpServletRequest request, + @PathVariable UUID templateId) { + Long userId = userAuthService.getUserIdFromToken(request); + preparationTemplateService.deleteTemplate(userId, templateId); + return ResponseEntity.ok(ApiResponseForm.success(null)); + } +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmWindowScheduleDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmWindowScheduleDto.java index 95f1cbd..3c5baa9 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmWindowScheduleDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmWindowScheduleDto.java @@ -1,6 +1,7 @@ package devkor.ontime_back.dto; import devkor.ontime_back.entity.DoneStatus; +import devkor.ontime_back.entity.PreparationMode; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -22,6 +23,11 @@ public class AlarmWindowScheduleDto { private DoneStatus doneStatus; private Instant startedAt; private Instant finishedAt; + private PreparationMode preparationMode; + private UUID preparationTemplateId; + private String preparationTemplateName; + private Boolean preparationTemplateDeleted; + private Boolean preparationFrozen; private LocalDateTime preparationStartTime; private LocalDateTime defaultAlarmTime; private List preparations; diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/OrderedPreparationDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/OrderedPreparationDto.java new file mode 100644 index 0000000..ffca81e --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/OrderedPreparationDto.java @@ -0,0 +1,35 @@ +package devkor.ontime_back.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OrderedPreparationDto { + @NotNull(message = "preparationId는 필수입니다.") + private UUID preparationId; + + @NotBlank(message = "준비과정 이름은 필수입니다.") + @Size(max = 50, message = "준비과정 이름은 50자 이하여야 합니다.") + private String preparationName; + + @NotNull(message = "준비 시간은 필수입니다.") + @Min(value = 1, message = "준비 시간은 1 이상이어야 합니다.") + @Max(value = 1440, message = "준비 시간은 1440 이하여야 합니다.") + private Integer preparationTime; + + @NotNull(message = "orderIndex는 필수입니다.") + @Min(value = 0, message = "orderIndex는 0 이상이어야 합니다.") + private Integer orderIndex; +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/PreparationDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/PreparationDto.java index bbbf2f4..3fc0967 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/PreparationDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/PreparationDto.java @@ -22,7 +22,7 @@ public class PreparationDto { @Size(max = 50, message = "준비과정 이름은 50자 이하여야 합니다.") private String preparationName; @NotNull(message = "준비 시간은 필수입니다.") - @Min(value = 0, message = "준비 시간은 0 이상이어야 합니다.") + @Min(value = 1, message = "준비 시간은 1 이상이어야 합니다.") @Max(value = 1440, message = "준비 시간은 1440 이하여야 합니다.") private Integer preparationTime; private UUID nextPreparationId; diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/PreparationTemplateRequestDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/PreparationTemplateRequestDto.java new file mode 100644 index 0000000..02b53a2 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/PreparationTemplateRequestDto.java @@ -0,0 +1,30 @@ +package devkor.ontime_back.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.UUID; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PreparationTemplateRequestDto { + @NotNull(message = "templateId는 필수입니다.") + private UUID templateId; + + @NotBlank(message = "템플릿 이름은 필수입니다.") + @Size(max = 30, message = "템플릿 이름은 30자 이하여야 합니다.") + private String templateName; + + @NotEmpty(message = "준비과정은 하나 이상 필요합니다.") + private List<@Valid OrderedPreparationDto> preparations; +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/PreparationTemplateResponseDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/PreparationTemplateResponseDto.java new file mode 100644 index 0000000..bc669ca --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/PreparationTemplateResponseDto.java @@ -0,0 +1,21 @@ +package devkor.ontime_back.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Getter +@Builder +@AllArgsConstructor +public class PreparationTemplateResponseDto { + private UUID templateId; + private String templateName; + private Instant createdAt; + private Instant updatedAt; + private Instant deletedAt; + private List preparations; +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/PreparationTemplateUpdateDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/PreparationTemplateUpdateDto.java new file mode 100644 index 0000000..6287a7e --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/PreparationTemplateUpdateDto.java @@ -0,0 +1,25 @@ +package devkor.ontime_back.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PreparationTemplateUpdateDto { + @NotBlank(message = "템플릿 이름은 필수입니다.") + @Size(max = 30, message = "템플릿 이름은 30자 이하여야 합니다.") + private String templateName; + + @NotEmpty(message = "준비과정은 하나 이상 필요합니다.") + private List<@Valid OrderedPreparationDto> preparations; +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleAddDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleAddDto.java index 1cbf9af..1be5aa6 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleAddDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleAddDto.java @@ -4,6 +4,7 @@ import devkor.ontime_back.entity.Place; import devkor.ontime_back.entity.Schedule; import devkor.ontime_back.entity.User; +import jakarta.validation.Valid; import jakarta.validation.constraints.FutureOrPresent; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; @@ -15,6 +16,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.util.List; import java.util.UUID; @Getter @@ -41,11 +43,21 @@ public class ScheduleAddDto { private LocalDateTime scheduleTime; // 약속시각 private Boolean isChange; // 변경여부 private Boolean isStarted; // 버튼누름여부 + private UUID preparationTemplateId; + private List<@Valid OrderedPreparationDto> customPreparations; @Min(value = 0, message = "여유 시간은 0 이상이어야 합니다.") @Max(value = 1440, message = "여유 시간은 1440 이하여야 합니다.") private Integer scheduleSpareTime; // 스케줄 별 여유시간 @Size(max = 1000, message = "일정 메모는 1000자 이하여야 합니다.") private String scheduleNote; // 스케줄 별 주의사항 + + public ScheduleAddDto(UUID scheduleId, UUID placeId, String placeName, String scheduleName, + Integer moveTime, LocalDateTime scheduleTime, Boolean isChange, Boolean isStarted, + Integer scheduleSpareTime, String scheduleNote) { + this(scheduleId, placeId, placeName, scheduleName, moveTime, scheduleTime, + isChange, isStarted, null, null, scheduleSpareTime, scheduleNote); + } + public Schedule toEntity(User user, Place place) { return Schedule.builder() .user(user) @@ -58,6 +70,8 @@ public Schedule toEntity(User user, Place place) { .isStarted(false) .startedAt(null) .finishedAt(null) + .preparationMode(null) + .preparationTemplate(null) .scheduleSpareTime(this.scheduleSpareTime) .latenessTime(-1) .scheduleNote(this.scheduleNote) diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleDto.java index 3b8af77..75c6e13 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleDto.java @@ -1,9 +1,7 @@ package devkor.ontime_back.dto; import devkor.ontime_back.entity.DoneStatus; -import devkor.ontime_back.entity.Place; -import devkor.ontime_back.entity.User; -import jakarta.persistence.*; +import devkor.ontime_back.entity.PreparationMode; import lombok.*; import java.time.Instant; import java.time.LocalDateTime; @@ -24,18 +22,23 @@ public class ScheduleDto { private DoneStatus doneStatus; private Instant startedAt; private Instant finishedAt; + private PreparationMode preparationMode; + private UUID preparationTemplateId; + private String preparationTemplateName; + private Boolean preparationTemplateDeleted; + private Boolean preparationFrozen; public ScheduleDto(UUID scheduleId, PlaceDto place, String scheduleName, Integer moveTime, LocalDateTime scheduleTime, Integer scheduleSpareTime, String scheduleNote, Integer latenessTime, DoneStatus doneStatus) { this(scheduleId, place, scheduleName, moveTime, scheduleTime, scheduleSpareTime, - scheduleNote, latenessTime, doneStatus, null, null); + scheduleNote, latenessTime, doneStatus, null, null, null, null, null, null, null); } public ScheduleDto(UUID scheduleId, PlaceDto place, String scheduleName, Integer moveTime, LocalDateTime scheduleTime, Integer scheduleSpareTime, String scheduleNote, Integer latenessTime, DoneStatus doneStatus, Instant startedAt) { this(scheduleId, place, scheduleName, moveTime, scheduleTime, scheduleSpareTime, - scheduleNote, latenessTime, doneStatus, startedAt, null); + scheduleNote, latenessTime, doneStatus, startedAt, null, null, null, null, null, null); } } diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleModDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleModDto.java index e66631d..bc36798 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleModDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/ScheduleModDto.java @@ -1,5 +1,7 @@ package devkor.ontime_back.dto; +import devkor.ontime_back.entity.PreparationMode; +import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; @@ -9,7 +11,9 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; + import java.time.LocalDateTime; +import java.util.List; import java.util.UUID; @Getter @@ -28,15 +32,25 @@ public class ScheduleModDto { @NotNull(message = "이동 시간은 필수입니다.") @Min(value = 0, message = "이동 시간은 0 이상이어야 합니다.") @Max(value = 1440, message = "이동 시간은 1440 이하여야 합니다.") - private Integer moveTime; // 이동시간 + private Integer moveTime; @NotNull(message = "일정 시간은 필수입니다.") - private LocalDateTime scheduleTime; // 약속시각 + private LocalDateTime scheduleTime; @Min(value = 0, message = "여유 시간은 0 이상이어야 합니다.") @Max(value = 1440, message = "여유 시간은 1440 이하여야 합니다.") - private Integer scheduleSpareTime; // 스케줄 별 여유시간 + private Integer scheduleSpareTime; @Min(value = 0, message = "지각 시간은 0 이상이어야 합니다.") @Max(value = 1440, message = "지각 시간은 1440 이하여야 합니다.") private Integer latenessTime; @Size(max = 1000, message = "일정 메모는 1000자 이하여야 합니다.") - private String scheduleNote; // 스케줄 별 주의사항 + private String scheduleNote; + private PreparationMode preparationMode; + private UUID preparationTemplateId; + private List<@Valid OrderedPreparationDto> customPreparations; + + public ScheduleModDto(UUID placeId, String placeName, String scheduleName, Integer moveTime, + LocalDateTime scheduleTime, Integer scheduleSpareTime, Integer latenessTime, + String scheduleNote) { + this(placeId, placeName, scheduleName, moveTime, scheduleTime, scheduleSpareTime, + latenessTime, scheduleNote, null, null, null); + } } diff --git a/ontime-back/src/main/java/devkor/ontime_back/entity/PreparationMode.java b/ontime-back/src/main/java/devkor/ontime_back/entity/PreparationMode.java new file mode 100644 index 0000000..16d23d9 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/entity/PreparationMode.java @@ -0,0 +1,7 @@ +package devkor.ontime_back.entity; + +public enum PreparationMode { + DEFAULT, + TEMPLATE, + CUSTOM +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/entity/PreparationSchedule.java b/ontime-back/src/main/java/devkor/ontime_back/entity/PreparationSchedule.java index 6e9a450..734acea 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/entity/PreparationSchedule.java +++ b/ontime-back/src/main/java/devkor/ontime_back/entity/PreparationSchedule.java @@ -29,6 +29,9 @@ public class PreparationSchedule { private Integer preparationTime; + @Column(name = "order_index") + private Integer orderIndex; + @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "next_preparation_id") @OnDelete(action = OnDeleteAction.SET_NULL) @@ -37,4 +40,8 @@ public class PreparationSchedule { public void updateNextPreparation(PreparationSchedule nextPreparation) { this.nextPreparation = nextPreparation; } + + public PreparationSchedule(UUID preparationScheduleId, Schedule schedule, String preparationName, Integer preparationTime, PreparationSchedule nextPreparation) { + this(preparationScheduleId, schedule, preparationName, preparationTime, null, nextPreparation); + } } diff --git a/ontime-back/src/main/java/devkor/ontime_back/entity/PreparationTemplate.java b/ontime-back/src/main/java/devkor/ontime_back/entity/PreparationTemplate.java new file mode 100644 index 0000000..26b67df --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/entity/PreparationTemplate.java @@ -0,0 +1,69 @@ +package devkor.ontime_back.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +import java.time.Instant; +import java.util.UUID; + +@Getter +@Entity +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Table( + indexes = { + @Index(name = "idx_preparation_template_user_deleted", columnList = "user_id, deleted_at"), + @Index(name = "idx_preparation_template_created", columnList = "created_at") + } +) +public class PreparationTemplate { + @Id + private UUID preparationTemplateId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + private User user; + + @Column(nullable = false, length = 30) + private String templateName; + + @Column(nullable = false, length = 30) + private String normalizedTemplateName; + + @Column(nullable = false) + private Instant createdAt; + + @Column(nullable = false) + private Instant updatedAt; + + private Instant deletedAt; + + @PrePersist + private void initializeTimestamps() { + Instant now = Instant.now(); + if (createdAt == null) createdAt = now; + if (updatedAt == null) updatedAt = now; + } + + public boolean isDeleted() { + return deletedAt != null; + } + + public void update(String templateName, String normalizedTemplateName, Instant updatedAt) { + this.templateName = templateName; + this.normalizedTemplateName = normalizedTemplateName; + this.updatedAt = updatedAt; + } + + public void softDelete(Instant deletedAt) { + this.deletedAt = deletedAt; + this.updatedAt = deletedAt; + } +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/entity/PreparationTemplateStep.java b/ontime-back/src/main/java/devkor/ontime_back/entity/PreparationTemplateStep.java new file mode 100644 index 0000000..c9bb7d4 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/entity/PreparationTemplateStep.java @@ -0,0 +1,38 @@ +package devkor.ontime_back.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +import java.util.UUID; + +@Getter +@Entity +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Table(indexes = { + @Index(name = "idx_preparation_template_step_template_order", columnList = "preparation_template_id, order_index") +}) +public class PreparationTemplateStep { + @Id + private UUID preparationTemplateStepId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "preparation_template_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + private PreparationTemplate preparationTemplate; + + @Column(nullable = false, length = 50) + private String preparationName; + + @Column(nullable = false) + private Integer preparationTime; + + @Column(nullable = false) + private Integer orderIndex; +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/entity/PreparationUser.java b/ontime-back/src/main/java/devkor/ontime_back/entity/PreparationUser.java index 275fe92..4e7c0ce 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/entity/PreparationUser.java +++ b/ontime-back/src/main/java/devkor/ontime_back/entity/PreparationUser.java @@ -29,6 +29,9 @@ public class PreparationUser { private Integer preparationTime; + @Column(name = "order_index") + private Integer orderIndex; + @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "next_preparation_id") @OnDelete(action = OnDeleteAction.SET_NULL) @@ -38,4 +41,8 @@ public void updateNextPreparation(PreparationUser nextPreparation) { this.nextPreparation = nextPreparation; } + public PreparationUser(UUID preparationUserId, User user, String preparationName, Integer preparationTime, PreparationUser nextPreparation) { + this(preparationUserId, user, preparationName, preparationTime, null, nextPreparation); + } + } diff --git a/ontime-back/src/main/java/devkor/ontime_back/entity/Schedule.java b/ontime-back/src/main/java/devkor/ontime_back/entity/Schedule.java index 3dbf587..6dadb95 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/entity/Schedule.java +++ b/ontime-back/src/main/java/devkor/ontime_back/entity/Schedule.java @@ -56,6 +56,14 @@ public class Schedule { @Enumerated(EnumType.STRING) private DoneStatus doneStatus; + @Enumerated(EnumType.STRING) + private PreparationMode preparationMode; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "preparation_template_id") + @OnDelete(action = OnDeleteAction.SET_NULL) + private PreparationTemplate preparationTemplate; + private Integer scheduleSpareTime; // 스케줄 별 여유시간 private Integer latenessTime; // 지각 시간 (NULL이면 약속 전, 0이면 약속 성공, N(양수)면 N분 지각) @@ -64,6 +72,13 @@ public class Schedule { @Column(columnDefinition = "TEXT") // 명시적으로 TEXT 타입으로 정의 private String scheduleNote; // 스케줄 별 주의사항 + @PrePersist + private void initializePreparationMode() { + if (preparationMode == null) { + preparationMode = Boolean.TRUE.equals(isChange) ? PreparationMode.CUSTOM : PreparationMode.DEFAULT; + } + } + public void updateSchedule(Place place, ScheduleModDto scheduleModDto) { this.place = place; this.scheduleName = scheduleModDto.getScheduleName(); @@ -80,6 +95,31 @@ public void startSchedule(Instant startedAt) { public void changePreparationSchedule() {this.isChange = true;} + public void useDefaultPreparation() { + this.preparationMode = PreparationMode.DEFAULT; + this.preparationTemplate = null; + this.isChange = false; + } + + public void useTemplatePreparation(PreparationTemplate preparationTemplate) { + this.preparationMode = PreparationMode.TEMPLATE; + this.preparationTemplate = preparationTemplate; + this.isChange = false; + } + + public void useCustomPreparation() { + this.preparationMode = PreparationMode.CUSTOM; + this.preparationTemplate = null; + this.isChange = true; + } + + public PreparationMode effectivePreparationMode() { + if (preparationMode != null) { + return preparationMode; + } + return Boolean.TRUE.equals(isChange) ? PreparationMode.CUSTOM : PreparationMode.DEFAULT; + } + public void updateLatenessTime(Integer latenessTime) { this.latenessTime = latenessTime; diff --git a/ontime-back/src/main/java/devkor/ontime_back/repository/PreparationScheduleRepository.java b/ontime-back/src/main/java/devkor/ontime_back/repository/PreparationScheduleRepository.java index 03b7e53..e47f625 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/repository/PreparationScheduleRepository.java +++ b/ontime-back/src/main/java/devkor/ontime_back/repository/PreparationScheduleRepository.java @@ -16,10 +16,13 @@ public interface PreparationScheduleRepository extends JpaRepository findByScheduleWithNextPreparation(@Param("schedule") Schedule schedule); void deleteBySchedule(Schedule schedule); boolean existsBySchedule(Schedule schedule); + + boolean existsByPreparationScheduleIdAndSchedule( UUID preparationScheduleId, Schedule schedule); } diff --git a/ontime-back/src/main/java/devkor/ontime_back/repository/PreparationTemplateRepository.java b/ontime-back/src/main/java/devkor/ontime_back/repository/PreparationTemplateRepository.java new file mode 100644 index 0000000..47d62da --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/repository/PreparationTemplateRepository.java @@ -0,0 +1,32 @@ +package devkor.ontime_back.repository; + +import devkor.ontime_back.entity.PreparationTemplate; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface PreparationTemplateRepository extends JpaRepository { + @Query("SELECT pt FROM PreparationTemplate pt " + + "WHERE pt.user.id = :userId AND pt.deletedAt IS NULL " + + "ORDER BY pt.createdAt ASC, pt.preparationTemplateId ASC") + List findActiveByUserId(@Param("userId") Long userId); + + @Query("SELECT pt FROM PreparationTemplate pt WHERE pt.preparationTemplateId = :templateId AND pt.user.id = :userId") + Optional findByIdAndUserId(@Param("templateId") UUID templateId, @Param("userId") Long userId); + + @Query("SELECT pt FROM PreparationTemplate pt " + + "WHERE pt.preparationTemplateId = :templateId AND pt.user.id = :userId AND pt.deletedAt IS NULL") + Optional findActiveByIdAndUserId(@Param("templateId") UUID templateId, @Param("userId") Long userId); + + boolean existsByUser_IdAndNormalizedTemplateNameAndDeletedAtIsNull(Long userId, String normalizedTemplateName); + + boolean existsByUser_IdAndNormalizedTemplateNameAndDeletedAtIsNullAndPreparationTemplateIdNot(Long userId, String normalizedTemplateName, UUID preparationTemplateId); + + long countByUser_IdAndDeletedAtIsNull(Long userId); +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/repository/PreparationTemplateStepRepository.java b/ontime-back/src/main/java/devkor/ontime_back/repository/PreparationTemplateStepRepository.java new file mode 100644 index 0000000..0ea126d --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/repository/PreparationTemplateStepRepository.java @@ -0,0 +1,23 @@ +package devkor.ontime_back.repository; + +import devkor.ontime_back.entity.PreparationTemplate; +import devkor.ontime_back.entity.PreparationTemplateStep; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface PreparationTemplateStepRepository extends JpaRepository { + @Query("SELECT pts FROM PreparationTemplateStep pts " + + "WHERE pts.preparationTemplate = :template " + + "ORDER BY pts.orderIndex ASC, pts.preparationTemplateStepId ASC") + List findByPreparationTemplateOrdered(@Param("template") PreparationTemplate template); + + void deleteByPreparationTemplate(PreparationTemplate preparationTemplate); + + boolean existsByPreparationTemplateStepIdAndPreparationTemplate(UUID preparationTemplateStepId, PreparationTemplate preparationTemplate); +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/repository/PreparationUserRepository.java b/ontime-back/src/main/java/devkor/ontime_back/repository/PreparationUserRepository.java index 659e055..024c716 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/repository/PreparationUserRepository.java +++ b/ontime-back/src/main/java/devkor/ontime_back/repository/PreparationUserRepository.java @@ -18,9 +18,12 @@ public interface PreparationUserRepository extends JpaRepository findByUserIdWithNextPreparation(@Param("userId") Long userId); + boolean existsByPreparationUserIdAndUser_Id(UUID preparationUserId, Long userId); + @Query("SELECT pu FROM PreparationUser pu " + "LEFT JOIN FETCH pu.nextPreparation " + "WHERE pu.user.id = :userId " + diff --git a/ontime-back/src/main/java/devkor/ontime_back/repository/ScheduleRepository.java b/ontime-back/src/main/java/devkor/ontime_back/repository/ScheduleRepository.java index 6f82770..1f97ae6 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/repository/ScheduleRepository.java +++ b/ontime-back/src/main/java/devkor/ontime_back/repository/ScheduleRepository.java @@ -76,4 +76,23 @@ List findAlarmWindowSchedules(@Param("userId") Long userId, "AND NOT EXISTS (SELECT ps FROM PreparationSchedule ps WHERE ps.schedule = s)") List findStartedSchedulesWithoutPreparationSnapshot(); + @Query("SELECT s FROM Schedule s " + + "JOIN FETCH s.user " + + "LEFT JOIN FETCH s.place " + + "LEFT JOIN FETCH s.preparationTemplate " + + "WHERE s.preparationMode = devkor.ontime_back.entity.PreparationMode.TEMPLATE " + + "AND s.preparationTemplate.preparationTemplateId = :templateId " + + "AND s.doneStatus = devkor.ontime_back.entity.DoneStatus.NOT_ENDED " + + "AND s.startedAt IS NULL") + List findNotStartedTemplateModeSchedules(@Param("templateId") UUID templateId); + + @Query("SELECT s FROM Schedule s " + + "JOIN FETCH s.user " + + "LEFT JOIN FETCH s.place " + + "WHERE s.user.id = :userId " + + "AND s.preparationMode = devkor.ontime_back.entity.PreparationMode.DEFAULT " + + "AND s.doneStatus = devkor.ontime_back.entity.DoneStatus.NOT_ENDED " + + "AND s.startedAt IS NULL") + List findNotStartedDefaultModeSchedules(@Param("userId") Long userId); + } diff --git a/ontime-back/src/main/java/devkor/ontime_back/response/ErrorCode.java b/ontime-back/src/main/java/devkor/ontime_back/response/ErrorCode.java index 3cb837b..f85947e 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/response/ErrorCode.java +++ b/ontime-back/src/main/java/devkor/ontime_back/response/ErrorCode.java @@ -37,6 +37,11 @@ public enum ErrorCode { ALARM_SETTINGS_INVALID_FIELD(1101, "ALARM_SETTINGS_INVALID_FIELD", HttpStatus.BAD_REQUEST), ALARM_WINDOW_RANGE_TOO_LONG(1102, "ALARM_WINDOW_RANGE_TOO_LONG", HttpStatus.BAD_REQUEST), DEVICE_SESSION_NOT_ACTIVE(1103, "DEVICE_SESSION_NOT_ACTIVE", HttpStatus.CONFLICT), + PREPARATION_TEMPLATE_NOT_FOUND(1201, "PREPARATION_TEMPLATE_NOT_FOUND", HttpStatus.NOT_FOUND), + PREPARATION_TEMPLATE_NAME_DUPLICATE(1202, "PREPARATION_TEMPLATE_NAME_DUPLICATE", HttpStatus.CONFLICT), + PREPARATION_TEMPLATE_LIMIT_EXCEEDED(1203, "PREPARATION_TEMPLATE_LIMIT_EXCEEDED", HttpStatus.CONFLICT), + PREPARATION_TEMPLATE_DELETED(1204, "PREPARATION_TEMPLATE_DELETED", HttpStatus.CONFLICT), + PREPARATION_STEP_ID_CONFLICT(1205, "PREPARATION_STEP_ID_CONFLICT", HttpStatus.CONFLICT), // 공통 오류 메시지 UNEXPECTED_ERROR(1000, "Unexpected Error: An unexpected error occurred.", HttpStatus.INTERNAL_SERVER_ERROR),; diff --git a/ontime-back/src/main/java/devkor/ontime_back/service/PreparationScheduleService.java b/ontime-back/src/main/java/devkor/ontime_back/service/PreparationScheduleService.java index 3ee8b59..8c89962 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/service/PreparationScheduleService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/service/PreparationScheduleService.java @@ -1,29 +1,16 @@ package devkor.ontime_back.service; import devkor.ontime_back.dto.PreparationDto; -import devkor.ontime_back.entity.NotificationSchedule; -import devkor.ontime_back.entity.PreparationSchedule; -import devkor.ontime_back.entity.Schedule; -import devkor.ontime_back.entity.User; -import devkor.ontime_back.global.jwt.JwtTokenProvider; -import devkor.ontime_back.repository.NotificationScheduleRepository; import devkor.ontime_back.repository.PreparationScheduleRepository; import devkor.ontime_back.repository.ScheduleRepository; import devkor.ontime_back.repository.UserRepository; -import devkor.ontime_back.response.GeneralException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.UUID; -import java.util.stream.Collectors; - -import static devkor.ontime_back.response.ErrorCode.*; @Service @Transactional(readOnly = true) @@ -34,8 +21,6 @@ public class PreparationScheduleService { private final PreparationScheduleRepository preparationScheduleRepository; private final UserRepository userRepository; private final ScheduleRepository scheduleRepository; - private final JwtTokenProvider jwtTokenProvider; - private final NotificationScheduleRepository notificationScheduleRepository; @Transactional @@ -50,56 +35,6 @@ public void updatePreparationSchedules(Long userId, UUID scheduleId, List preparationDtoList, boolean shouldDelete) { - Schedule schedule = scheduleRepository.findByIdWithUserAndPlaceForUpdate(scheduleId) - .orElseThrow(() -> new GeneralException(SCHEDULE_NOT_FOUND)); - - if (!schedule.getUser().getId().equals(userId)) { - throw new GeneralException(UNAUTHORIZED_ACCESS); - } - scheduleService.assertScheduleEditable(schedule); - - if (shouldDelete) { - preparationScheduleRepository.deleteBySchedule(schedule); - } - - schedule.changePreparationSchedule(); - scheduleRepository.save(schedule); - - Map preparationMap = new HashMap<>(); - - List preparationSchedules = preparationDtoList.stream() - .map(dto -> { - PreparationSchedule preparation = new PreparationSchedule( - dto.getPreparationId(), - schedule, - dto.getPreparationName(), - dto.getPreparationTime(), - null); - preparationMap.put(dto.getPreparationId(), preparation); - return preparation; - }) - .collect(Collectors.toList()); - - preparationScheduleRepository.saveAll(preparationSchedules); - - preparationDtoList.stream() - .filter(dto -> dto.getNextPreparationId() != null) - .forEach(dto -> { - PreparationSchedule current = preparationMap.get(dto.getPreparationId()); - PreparationSchedule nextPreparation = preparationMap.get(dto.getNextPreparationId()); - if (nextPreparation != null) { - current.updateNextPreparation(nextPreparation); - } - }); - - preparationScheduleRepository.saveAll(preparationSchedules); - - NotificationSchedule notification = notificationScheduleRepository.findByScheduleScheduleId(scheduleId) - .orElseThrow(() -> new GeneralException(NOTIFICATION_NOT_FOUND)); - - LocalDateTime notificationTime = scheduleService.getNotificationTime(schedule, schedule.getUser()); - log.info("Notification Time(변경된): " + notificationTime); - - scheduleService.updateAndRescheduleNotification(notificationTime, notification); + scheduleService.replaceScheduleCustomPreparations(userId, scheduleId, preparationDtoList, shouldDelete); } } diff --git a/ontime-back/src/main/java/devkor/ontime_back/service/PreparationStepService.java b/ontime-back/src/main/java/devkor/ontime_back/service/PreparationStepService.java new file mode 100644 index 0000000..06be8ab --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/service/PreparationStepService.java @@ -0,0 +1,288 @@ +package devkor.ontime_back.service; + +import devkor.ontime_back.dto.OrderedPreparationDto; +import devkor.ontime_back.dto.PreparationDto; +import devkor.ontime_back.entity.PreparationSchedule; +import devkor.ontime_back.entity.PreparationTemplate; +import devkor.ontime_back.entity.PreparationTemplateStep; +import devkor.ontime_back.entity.PreparationUser; +import devkor.ontime_back.entity.Schedule; +import devkor.ontime_back.repository.PreparationScheduleRepository; +import devkor.ontime_back.repository.PreparationTemplateStepRepository; +import devkor.ontime_back.repository.PreparationUserRepository; +import devkor.ontime_back.response.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +import static devkor.ontime_back.response.ErrorCode.INVALID_INPUT; +import static devkor.ontime_back.response.ErrorCode.PREPARATION_STEP_ID_CONFLICT; + +@Service +@RequiredArgsConstructor +public class PreparationStepService { + public static final int MAX_STEP_COUNT = 50; + public static final int MAX_TOTAL_MINUTES = 1440; + + private final PreparationUserRepository preparationUserRepository; + private final PreparationScheduleRepository preparationScheduleRepository; + private final PreparationTemplateStepRepository preparationTemplateStepRepository; + + public List normalizeOrdered(List preparations) { + if (preparations == null || preparations.isEmpty() || preparations.size() > MAX_STEP_COUNT) { + throw new GeneralException(INVALID_INPUT); + } + Set ids = new HashSet<>(); + Set indexes = new HashSet<>(); + int total = 0; + for (OrderedPreparationDto preparation : preparations) { + if (preparation.getPreparationId() == null + || !ids.add(preparation.getPreparationId()) + || preparation.getOrderIndex() == null + || !indexes.add(preparation.getOrderIndex()) + || preparation.getPreparationName() == null + || preparation.getPreparationName().trim().isEmpty() + || preparation.getPreparationName().trim().length() > 50 + || preparation.getPreparationTime() == null + || preparation.getPreparationTime() < 1 + || preparation.getPreparationTime() > 1440) { + throw new GeneralException(INVALID_INPUT); + } + total += preparation.getPreparationTime(); + } + if (total > MAX_TOTAL_MINUTES) { + throw new GeneralException(INVALID_INPUT); + } + for (int i = 0; i < preparations.size(); i++) { + if (!indexes.contains(i)) { + throw new GeneralException(INVALID_INPUT); + } + } + return preparations.stream() + .map(preparation -> OrderedPreparationDto.builder() + .preparationId(preparation.getPreparationId()) + .preparationName(preparation.getPreparationName().trim()) + .preparationTime(preparation.getPreparationTime()) + .orderIndex(preparation.getOrderIndex()) + .build()) + .sorted(Comparator.comparing(OrderedPreparationDto::getOrderIndex)) + .collect(Collectors.toList()); + } + + public List normalizeLinked(List preparations) { + if (preparations == null || preparations.isEmpty() || preparations.size() > MAX_STEP_COUNT) { + throw new GeneralException(INVALID_INPUT); + } + + Map byId = new HashMap<>(); + Set referenced = new HashSet<>(); + int total = 0; + for (PreparationDto preparation : preparations) { + if (preparation.getPreparationId() == null + || byId.put(preparation.getPreparationId(), preparation) != null + || preparation.getPreparationName() == null + || preparation.getPreparationName().trim().isEmpty() + || preparation.getPreparationName().trim().length() > 50 + || preparation.getPreparationTime() == null + || preparation.getPreparationTime() < 1 + || preparation.getPreparationTime() > 1440) { + throw new GeneralException(INVALID_INPUT); + } + total += preparation.getPreparationTime(); + if (preparation.getNextPreparationId() != null) { + referenced.add(preparation.getNextPreparationId()); + } + } + if (total > MAX_TOTAL_MINUTES || !byId.keySet().containsAll(referenced)) { + throw new GeneralException(INVALID_INPUT); + } + + List heads = byId.keySet().stream() + .filter(id -> !referenced.contains(id)) + .collect(Collectors.toList()); + if (heads.size() != 1) { + throw new GeneralException(INVALID_INPUT); + } + + List ordered = new ArrayList<>(); + Set seen = new HashSet<>(); + UUID currentId = heads.get(0); + while (currentId != null) { + if (!seen.add(currentId)) { + throw new GeneralException(INVALID_INPUT); + } + PreparationDto current = byId.get(currentId); + if (current == null) { + throw new GeneralException(INVALID_INPUT); + } + ordered.add(OrderedPreparationDto.builder() + .preparationId(current.getPreparationId()) + .preparationName(current.getPreparationName().trim()) + .preparationTime(current.getPreparationTime()) + .orderIndex(ordered.size()) + .build()); + currentId = current.getNextPreparationId(); + } + if (seen.size() != preparations.size()) { + throw new GeneralException(INVALID_INPUT); + } + return ordered; + } + + public List toLinkedDtoFromUser(List preparations) { + if (preparations.stream().anyMatch(preparation -> preparation.getOrderIndex() == null)) { + Map byId = preparations.stream() + .collect(Collectors.toMap(PreparationUser::getPreparationUserId, preparation -> preparation)); + Set referenced = preparations.stream() + .map(PreparationUser::getNextPreparation) + .filter(Objects::nonNull) + .map(PreparationUser::getPreparationUserId) + .collect(Collectors.toSet()); + Optional head = preparations.stream() + .filter(preparation -> !referenced.contains(preparation.getPreparationUserId())) + .findFirst(); + if (head.isPresent()) { + List result = new ArrayList<>(); + Set seen = new HashSet<>(); + PreparationUser current = head.get(); + while (current != null && seen.add(current.getPreparationUserId())) { + UUID nextId = current.getNextPreparation() != null ? current.getNextPreparation().getPreparationUserId() : null; + result.add(new PreparationDto( + current.getPreparationUserId(), + current.getPreparationName(), + current.getPreparationTime(), + nextId + )); + current = nextId != null ? byId.get(nextId) : null; + } + if (result.size() == preparations.size()) { + return result; + } + } + } + return toLinkedDto(preparations.stream() + .map(preparation -> OrderedPreparationDto.builder() + .preparationId(preparation.getPreparationUserId()) + .preparationName(preparation.getPreparationName()) + .preparationTime(preparation.getPreparationTime()) + .orderIndex(preparation.getOrderIndex()) + .build()) + .collect(Collectors.toList())); + } + + public List toLinkedDtoFromSchedule(List preparations) { + if (preparations.stream().anyMatch(preparation -> preparation.getOrderIndex() == null)) { + Map byId = preparations.stream() + .collect(Collectors.toMap(PreparationSchedule::getPreparationScheduleId, preparation -> preparation)); + Set referenced = preparations.stream() + .map(PreparationSchedule::getNextPreparation) + .filter(Objects::nonNull) + .map(PreparationSchedule::getPreparationScheduleId) + .collect(Collectors.toSet()); + Optional head = preparations.stream() + .filter(preparation -> !referenced.contains(preparation.getPreparationScheduleId())) + .findFirst(); + if (head.isPresent()) { + List result = new ArrayList<>(); + Set seen = new HashSet<>(); + PreparationSchedule current = head.get(); + while (current != null && seen.add(current.getPreparationScheduleId())) { + UUID nextId = current.getNextPreparation() != null ? current.getNextPreparation().getPreparationScheduleId() : null; + result.add(new PreparationDto( + current.getPreparationScheduleId(), + current.getPreparationName(), + current.getPreparationTime(), + nextId + )); + current = nextId != null ? byId.get(nextId) : null; + } + if (result.size() == preparations.size()) { + return result; + } + } + } + return toLinkedDto(preparations.stream() + .map(preparation -> OrderedPreparationDto.builder() + .preparationId(preparation.getPreparationScheduleId()) + .preparationName(preparation.getPreparationName()) + .preparationTime(preparation.getPreparationTime()) + .orderIndex(preparation.getOrderIndex()) + .build()) + .collect(Collectors.toList())); + } + + public List toLinkedDtoFromTemplate(List preparations) { + return toLinkedDto(preparations.stream() + .map(preparation -> OrderedPreparationDto.builder() + .preparationId(preparation.getPreparationTemplateStepId()) + .preparationName(preparation.getPreparationName()) + .preparationTime(preparation.getPreparationTime()) + .orderIndex(preparation.getOrderIndex()) + .build()) + .collect(Collectors.toList())); + } + + public List toLinkedDto(List orderedPreparations) { + List sorted = orderedPreparations.stream() + .sorted(Comparator.comparing(OrderedPreparationDto::getOrderIndex, Comparator.nullsLast(Integer::compareTo))) + .toList(); + List result = new ArrayList<>(); + for (int i = 0; i < sorted.size(); i++) { + OrderedPreparationDto current = sorted.get(i); + UUID nextId = (i + 1 < sorted.size()) ? sorted.get(i + 1).getPreparationId() : null; + result.add(new PreparationDto( + current.getPreparationId(), + current.getPreparationName(), + current.getPreparationTime(), + nextId + )); + } + return result; + } + + public void assertStepIdsAvailableForDefault(List preparations, Long userId) { + for (OrderedPreparationDto preparation : preparations) { + UUID id = preparation.getPreparationId(); + if ((preparationUserRepository.existsById(id) && !preparationUserRepository.existsByPreparationUserIdAndUser_Id(id, userId)) + || preparationScheduleRepository.existsById(id) + || preparationTemplateStepRepository.existsById(id)) { + throw new GeneralException(PREPARATION_STEP_ID_CONFLICT); + } + } + } + + public void assertStepIdsAvailableForSchedule(List preparations, Schedule schedule) { + for (OrderedPreparationDto preparation : preparations) { + UUID id = preparation.getPreparationId(); + if (preparationUserRepository.existsById(id) + || (preparationScheduleRepository.existsById(id) && !preparationScheduleRepository.existsByPreparationScheduleIdAndSchedule(id, schedule)) + || preparationTemplateStepRepository.existsById(id)) { + throw new GeneralException(PREPARATION_STEP_ID_CONFLICT); + } + } + } + + public void assertStepIdsAvailableForTemplate(List preparations, PreparationTemplate template) { + for (OrderedPreparationDto preparation : preparations) { + UUID id = preparation.getPreparationId(); + if (preparationUserRepository.existsById(id) + || preparationScheduleRepository.existsById(id) + || (preparationTemplateStepRepository.existsById(id) && !preparationTemplateStepRepository.existsByPreparationTemplateStepIdAndPreparationTemplate(id, template))) { + throw new GeneralException(PREPARATION_STEP_ID_CONFLICT); + } + } + } + + public void assertStepIdsAvailableForNewTemplate(List preparations) { + for (OrderedPreparationDto preparation : preparations) { + UUID id = preparation.getPreparationId(); + if (preparationUserRepository.existsById(id) + || preparationScheduleRepository.existsById(id) + || preparationTemplateStepRepository.existsById(id)) { + throw new GeneralException(PREPARATION_STEP_ID_CONFLICT); + } + } + } +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/service/PreparationTemplateService.java b/ontime-back/src/main/java/devkor/ontime_back/service/PreparationTemplateService.java new file mode 100644 index 0000000..8b45dd4 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/service/PreparationTemplateService.java @@ -0,0 +1,183 @@ +package devkor.ontime_back.service; + +import devkor.ontime_back.dto.OrderedPreparationDto; +import devkor.ontime_back.dto.PreparationTemplateRequestDto; +import devkor.ontime_back.dto.PreparationTemplateResponseDto; +import devkor.ontime_back.dto.PreparationTemplateUpdateDto; +import devkor.ontime_back.entity.PreparationTemplate; +import devkor.ontime_back.entity.PreparationTemplateStep; +import devkor.ontime_back.entity.User; +import devkor.ontime_back.repository.PreparationTemplateRepository; +import devkor.ontime_back.repository.PreparationTemplateStepRepository; +import devkor.ontime_back.repository.UserRepository; +import devkor.ontime_back.response.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +import static devkor.ontime_back.response.ErrorCode.*; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class PreparationTemplateService { + private static final int ACTIVE_TEMPLATE_LIMIT = 20; + + private final PreparationTemplateRepository preparationTemplateRepository; + private final PreparationTemplateStepRepository preparationTemplateStepRepository; + private final UserRepository userRepository; + private final PreparationStepService preparationStepService; + private final ScheduleService scheduleService; + + public List listTemplates(Long userId) { + return preparationTemplateRepository.findActiveByUserId(userId).stream() + .map(template -> toResponse(template, false)) + .toList(); + } + + public PreparationTemplateResponseDto getTemplate(Long userId, UUID templateId) { + PreparationTemplate template = findOwnedTemplate(userId, templateId); + return toResponse(template, true); + } + + @Transactional + public PreparationTemplateResponseDto createTemplate(Long userId, PreparationTemplateRequestDto request) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(USER_NOT_FOUND)); + if (preparationTemplateRepository.existsById(request.getTemplateId())) { + throw new GeneralException(RESOURCE_ALREADY_EXISTS); + } + if (preparationTemplateRepository.countByUser_IdAndDeletedAtIsNull(userId) >= ACTIVE_TEMPLATE_LIMIT) { + throw new GeneralException(PREPARATION_TEMPLATE_LIMIT_EXCEEDED); + } + + String templateName = normalizeDisplayName(request.getTemplateName()); + String normalizedName = normalizeLookupName(templateName); + if (preparationTemplateRepository.existsByUser_IdAndNormalizedTemplateNameAndDeletedAtIsNull(userId, normalizedName)) { + throw new GeneralException(PREPARATION_TEMPLATE_NAME_DUPLICATE); + } + + List preparations = preparationStepService.normalizeOrdered(request.getPreparations()); + preparationStepService.assertStepIdsAvailableForNewTemplate(preparations); + + Instant now = now(); + PreparationTemplate template = PreparationTemplate.builder() + .preparationTemplateId(request.getTemplateId()) + .user(user) + .templateName(templateName) + .normalizedTemplateName(normalizedName) + .createdAt(now) + .updatedAt(now) + .build(); + preparationTemplateRepository.save(template); + saveSteps(template, preparations); + return toResponse(template, false); + } + + @Transactional + public PreparationTemplateResponseDto updateTemplate(Long userId, UUID templateId, PreparationTemplateUpdateDto request) { + PreparationTemplate template = findOwnedTemplate(userId, templateId); + if (template.isDeleted()) { + throw new GeneralException(PREPARATION_TEMPLATE_DELETED); + } + + String templateName = normalizeDisplayName(request.getTemplateName()); + String normalizedName = normalizeLookupName(templateName); + if (preparationTemplateRepository.existsByUser_IdAndNormalizedTemplateNameAndDeletedAtIsNullAndPreparationTemplateIdNot( + userId, normalizedName, templateId)) { + throw new GeneralException(PREPARATION_TEMPLATE_NAME_DUPLICATE); + } + + List preparations = preparationStepService.normalizeOrdered(request.getPreparations()); + preparationStepService.assertStepIdsAvailableForTemplate(preparations, template); + + template.update(templateName, normalizedName, now()); + preparationTemplateStepRepository.deleteByPreparationTemplate(template); + preparationTemplateStepRepository.flush(); + saveSteps(template, preparations); + preparationTemplateRepository.save(template); + scheduleService.refreshNotStartedTemplateModeSchedules(template.getPreparationTemplateId()); + return toResponse(template, false); + } + + @Transactional + public void deleteTemplate(Long userId, UUID templateId) { + PreparationTemplate template = findOwnedTemplate(userId, templateId); + if (template.isDeleted()) { + return; + } + template.softDelete(now()); + preparationTemplateRepository.save(template); + } + + public PreparationTemplate findActiveTemplateForSchedule(Long userId, UUID templateId) { + PreparationTemplate template = findOwnedTemplate(userId, templateId); + if (template.isDeleted()) { + throw new GeneralException(PREPARATION_TEMPLATE_DELETED); + } + return template; + } + + private PreparationTemplate findOwnedTemplate(Long userId, UUID templateId) { + return preparationTemplateRepository.findByIdAndUserId(templateId, userId) + .orElseThrow(() -> new GeneralException(PREPARATION_TEMPLATE_NOT_FOUND)); + } + + private void saveSteps(PreparationTemplate template, List preparations) { + List steps = preparations.stream() + .map(preparation -> PreparationTemplateStep.builder() + .preparationTemplateStepId(preparation.getPreparationId()) + .preparationTemplate(template) + .preparationName(preparation.getPreparationName()) + .preparationTime(preparation.getPreparationTime()) + .orderIndex(preparation.getOrderIndex()) + .build()) + .toList(); + preparationTemplateStepRepository.saveAll(steps); + } + + public PreparationTemplateResponseDto toResponse(PreparationTemplate template, boolean includeDeletedAt) { + List preparations = preparationTemplateStepRepository.findByPreparationTemplateOrdered(template).stream() + .map(step -> OrderedPreparationDto.builder() + .preparationId(step.getPreparationTemplateStepId()) + .preparationName(step.getPreparationName()) + .preparationTime(step.getPreparationTime()) + .orderIndex(step.getOrderIndex()) + .build()) + .toList(); + + return PreparationTemplateResponseDto.builder() + .templateId(template.getPreparationTemplateId()) + .templateName(template.getTemplateName()) + .createdAt(template.getCreatedAt()) + .updatedAt(template.getUpdatedAt()) + .deletedAt(includeDeletedAt ? template.getDeletedAt() : null) + .preparations(preparations) + .build(); + } + + private String normalizeDisplayName(String value) { + if (value == null) { + throw new GeneralException(INVALID_INPUT); + } + String trimmed = value.trim(); + if (trimmed.isEmpty() || trimmed.length() > 30) { + throw new GeneralException(INVALID_INPUT); + } + return trimmed; + } + + private String normalizeLookupName(String value) { + return value.trim().toLowerCase(Locale.ROOT); + } + + private Instant now() { + return Instant.now().truncatedTo(ChronoUnit.SECONDS); + } +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/service/PreparationUserService.java b/ontime-back/src/main/java/devkor/ontime_back/service/PreparationUserService.java index b45bb9c..f7bff93 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/service/PreparationUserService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/service/PreparationUserService.java @@ -2,16 +2,14 @@ import devkor.ontime_back.dto.PreparationDto; +import devkor.ontime_back.dto.OrderedPreparationDto; import devkor.ontime_back.entity.PreparationUser; import devkor.ontime_back.entity.User; -import devkor.ontime_back.global.jwt.JwtTokenProvider; import devkor.ontime_back.repository.PreparationUserRepository; import devkor.ontime_back.repository.UserRepository; -import devkor.ontime_back.response.ErrorCode; import devkor.ontime_back.response.GeneralException; -import jakarta.persistence.EntityNotFoundException; -import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -26,6 +24,8 @@ public class PreparationUserService { private final PreparationUserRepository preparationUserRepository; private final UserRepository userRepository; + private final PreparationStepService preparationStepService; + private final ObjectProvider scheduleServiceProvider; @Transactional // 회원가입 시 디폴트 준비과정 세팅 @@ -54,28 +54,18 @@ public void updatePreparationUsers(Long userId, List preparation // 준비과정 불러오기 public List showAllPreparationUsers(Long userId) { - PreparationUser firstPreparation = preparationUserRepository.findFirstPreparationUserByUserIdWithNextPreparation(userId) - .orElseThrow(() -> new GeneralException(FIRST_PREPARATION_NOT_FOUND)); - - List preparationDtos = new ArrayList<>(); - PreparationUser current = firstPreparation; - - while (current != null) { - PreparationDto dto = new PreparationDto( - current.getPreparationUserId(), - current.getPreparationName(), - current.getPreparationTime(), - current.getNextPreparation() != null ? current.getNextPreparation().getPreparationUserId() : null - ); - preparationDtos.add(dto); - current = current.getNextPreparation(); + List preparations = preparationUserRepository.findByUserIdWithNextPreparation(userId); + if (preparations.isEmpty()) { + throw new GeneralException(FIRST_PREPARATION_NOT_FOUND); } - - return preparationDtos; + return preparationStepService.toLinkedDtoFromUser(preparations); } @Transactional protected void handlePreparationUsers(User user, List preparationDtoList, boolean shouldDeleteExisting) { + List orderedPreparations = preparationStepService.normalizeLinked(preparationDtoList); + preparationStepService.assertStepIdsAvailableForDefault(orderedPreparations, user.getId()); + if (shouldDeleteExisting) { preparationUserRepository.deleteByUser(user); preparationUserRepository.flush(); @@ -83,13 +73,14 @@ protected void handlePreparationUsers(User user, List preparatio Map preparationMap = new HashMap<>(); - List preparationUsers = preparationDtoList.stream() + List preparationUsers = orderedPreparations.stream() .map(dto -> { PreparationUser preparation = new PreparationUser( dto.getPreparationId(), user, dto.getPreparationName(), dto.getPreparationTime(), + dto.getOrderIndex(), null // nextPreparation 설정은 나중에 ); preparationMap.put(dto.getPreparationId(), preparation); @@ -100,18 +91,17 @@ protected void handlePreparationUsers(User user, List preparatio preparationUserRepository.saveAll(preparationUsers); preparationUserRepository.flush(); - preparationDtoList.stream() - .filter(dto -> dto.getNextPreparationId() != null) - .forEach(dto -> { - PreparationUser current = preparationMap.get(dto.getPreparationId()); - PreparationUser nextPreparation = preparationMap.get(dto.getNextPreparationId()); - if (nextPreparation != null) { - current.updateNextPreparation(nextPreparation); - } - }); + for (int i = 0; i < orderedPreparations.size() - 1; i++) { + PreparationUser current = preparationMap.get(orderedPreparations.get(i).getPreparationId()); + PreparationUser nextPreparation = preparationMap.get(orderedPreparations.get(i + 1).getPreparationId()); + current.updateNextPreparation(nextPreparation); + } preparationUserRepository.saveAll(preparationUsers); + if (shouldDeleteExisting) { + scheduleServiceProvider.getObject().refreshNotStartedDefaultModeSchedules(user.getId()); + } } -} \ No newline at end of file +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/service/ScheduleService.java b/ontime-back/src/main/java/devkor/ontime_back/service/ScheduleService.java index 9255039..8dc89dc 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/service/ScheduleService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/service/ScheduleService.java @@ -37,7 +37,10 @@ public class ScheduleService { private final PlaceRepository placeRepository; private final PreparationScheduleRepository preparationScheduleRepository; private final PreparationUserRepository preparationUserRepository; + private final PreparationTemplateRepository preparationTemplateRepository; + private final PreparationTemplateStepRepository preparationTemplateStepRepository; private final NotificationScheduleRepository notificationScheduleRepository; + private final PreparationStepService preparationStepService; // scheduleId, userId를 통한 권한 확인 private Schedule getScheduleWithAuthorization(UUID scheduleId, Long userId) { @@ -136,13 +139,11 @@ public void modifySchedule(Long userId, UUID scheduleId, ScheduleModDto schedule .orElseGet(() -> placeRepository.save(new Place(scheduleModDto.getPlaceId(), scheduleModDto.getPlaceName()))); schedule.updateSchedule(place, scheduleModDto); + applyModifyPreparationMode(schedule, userId, scheduleModDto); scheduleRepository.save(schedule); - NotificationSchedule notification = notificationScheduleRepository.findByScheduleScheduleId(scheduleId) - .orElseThrow(() -> new GeneralException(NOTIFICATION_NOT_FOUND)); - LocalDateTime newNotificationTime = getNotificationTime(schedule, user); - updateAndRescheduleNotification(newNotificationTime, notification); + refreshScheduleNotification(schedule); } public void updateAndRescheduleNotification(LocalDateTime newNotificationTime, NotificationSchedule notification) { @@ -165,8 +166,13 @@ public void addSchedule(ScheduleAddDto scheduleAddDto, Long userId) { .orElseGet(() -> placeRepository.save(new Place(scheduleAddDto.getPlaceId(), scheduleAddDto.getPlaceName()))); Schedule schedule = scheduleAddDto.toEntity(user, place); + applyCreatePreparationMode(schedule, userId, scheduleAddDto); scheduleRepository.save(schedule); + if (schedule.effectivePreparationMode() == PreparationMode.CUSTOM) { + replaceSchedulePreparations(schedule, preparationStepService.normalizeOrdered(scheduleAddDto.getCustomPreparations())); + } + LocalDateTime notificationTime = getNotificationTime(schedule, user); NotificationSchedule notification = NotificationSchedule.builder() @@ -197,44 +203,103 @@ public StartScheduleResponseDto startSchedule(Long userId, UUID scheduleId) { } private void freezePreparationSnapshotIfNeeded(Schedule schedule) { - boolean hasScheduleSpecificPreparations = preparationScheduleRepository.existsBySchedule(schedule); - if (!hasScheduleSpecificPreparations) { + PreparationMode mode = schedule.effectivePreparationMode(); + if (mode == PreparationMode.CUSTOM) { + schedule.changePreparationSchedule(); + return; + } + preparationScheduleRepository.deleteBySchedule(schedule); + if (mode == PreparationMode.TEMPLATE) { + copyTemplatePreparationsToSchedule(schedule); + } else { copyDefaultPreparationsToSchedule(schedule); } + preparationScheduleRepository.flush(); schedule.changePreparationSchedule(); } private void copyDefaultPreparationsToSchedule(Schedule schedule) { List defaultPreparations = preparationUserRepository.findByUserIdWithNextPreparation(schedule.getUser().getId()); - Map preparationMap = new HashMap<>(); + List linkedPreparations = preparationStepService.toLinkedDtoFromUser(defaultPreparations); + List orderedPreparations = new java.util.ArrayList<>(); + for (int i = 0; i < linkedPreparations.size(); i++) { + PreparationDto defaultPreparation = linkedPreparations.get(i); + orderedPreparations.add(OrderedPreparationDto.builder() + .preparationId(UUID.randomUUID()) + .preparationName(defaultPreparation.getPreparationName()) + .preparationTime(defaultPreparation.getPreparationTime()) + .orderIndex(i) + .build()); + } + saveSchedulePreparations(schedule, orderedPreparations); + } - List snapshots = defaultPreparations.stream() - .map(defaultPreparation -> { - PreparationSchedule snapshot = new PreparationSchedule( - UUID.randomUUID(), + private void copyTemplatePreparationsToSchedule(Schedule schedule) { + PreparationTemplate template = schedule.getPreparationTemplate(); + if (template == null) { + throw new GeneralException(PREPARATION_TEMPLATE_NOT_FOUND); + } + List orderedPreparations = preparationTemplateStepRepository.findByPreparationTemplateOrdered(template).stream() + .map(templateStep -> OrderedPreparationDto.builder() + .preparationId(UUID.randomUUID()) + .preparationName(templateStep.getPreparationName()) + .preparationTime(templateStep.getPreparationTime()) + .orderIndex(defaultNonNegative(templateStep.getOrderIndex())) + .build()) + .collect(Collectors.toList()); + saveSchedulePreparations(schedule, orderedPreparations); + } + + @Transactional + public void replaceScheduleCustomPreparations(Long userId, UUID scheduleId, List preparationDtoList, boolean shouldDelete) { + Schedule schedule = getLockedScheduleWithAuthorization(scheduleId, userId); + assertScheduleEditable(schedule); + List orderedPreparations = preparationStepService.normalizeLinked(preparationDtoList); + preparationStepService.assertStepIdsAvailableForSchedule(orderedPreparations, schedule); + if (shouldDelete || preparationScheduleRepository.existsBySchedule(schedule)) { + preparationScheduleRepository.deleteBySchedule(schedule); + preparationScheduleRepository.flush(); + } + schedule.useCustomPreparation(); + scheduleRepository.save(schedule); + saveSchedulePreparations(schedule, orderedPreparations); + refreshScheduleNotification(schedule); + } + + private void replaceSchedulePreparations(Schedule schedule, List orderedPreparations) { + preparationStepService.assertStepIdsAvailableForSchedule(orderedPreparations, schedule); + preparationScheduleRepository.deleteBySchedule(schedule); + preparationScheduleRepository.flush(); + saveSchedulePreparations(schedule, orderedPreparations); + } + + private void saveSchedulePreparations(Schedule schedule, List orderedPreparations) { + Map preparationMap = new HashMap<>(); + List preparationSchedules = orderedPreparations.stream() + .map(dto -> { + PreparationSchedule preparation = new PreparationSchedule( + dto.getPreparationId(), schedule, - defaultPreparation.getPreparationName(), - defaultPreparation.getPreparationTime(), + dto.getPreparationName(), + dto.getPreparationTime(), + dto.getOrderIndex(), null ); - preparationMap.put(defaultPreparation.getPreparationUserId(), snapshot); - return snapshot; + preparationMap.put(dto.getPreparationId(), preparation); + return preparation; }) .collect(Collectors.toList()); - preparationScheduleRepository.saveAll(snapshots); + preparationScheduleRepository.saveAll(preparationSchedules); + preparationScheduleRepository.flush(); - defaultPreparations.stream() - .filter(defaultPreparation -> defaultPreparation.getNextPreparation() != null) - .forEach(defaultPreparation -> { - PreparationSchedule current = preparationMap.get(defaultPreparation.getPreparationUserId()); - PreparationSchedule next = preparationMap.get(defaultPreparation.getNextPreparation().getPreparationUserId()); - if (current != null && next != null) { - current.updateNextPreparation(next); - } - }); + for (int i = 0; i < orderedPreparations.size() - 1; i++) { + PreparationSchedule current = preparationMap.get(orderedPreparations.get(i).getPreparationId()); + PreparationSchedule nextPreparation = preparationMap.get(orderedPreparations.get(i + 1).getPreparationId()); + current.updateNextPreparation(nextPreparation); + } - preparationScheduleRepository.saveAll(snapshots); + preparationScheduleRepository.saveAll(preparationSchedules); } @Transactional @@ -310,29 +375,7 @@ public void finishSchedule(Long userId, UUID scheduleId, FinishPreparationDto fi public List getPreparations(Long userId, UUID scheduleId) { Schedule schedule = getScheduleWithAuthorization(scheduleId, userId); - if (schedule.getStartedAt() != null || Boolean.TRUE.equals(schedule.getIsChange())) { - return preparationScheduleRepository.findByScheduleWithNextPreparation(schedule).stream() - .map(preparationSchedule -> new PreparationDto( - preparationSchedule.getPreparationScheduleId(), - preparationSchedule.getPreparationName(), - defaultNonNegative(preparationSchedule.getPreparationTime()), - preparationSchedule.getNextPreparation() != null - ? preparationSchedule.getNextPreparation().getPreparationScheduleId() - : null - )) - .collect(Collectors.toList()); - } else { - return preparationUserRepository.findByUserIdWithNextPreparation(schedule.getUser().getId()).stream() - .map(preparationUser -> new PreparationDto( - preparationUser.getPreparationUserId(), - preparationUser.getPreparationName(), - defaultNonNegative(preparationUser.getPreparationTime()), - preparationUser.getNextPreparation() != null - ? preparationUser.getNextPreparation().getPreparationUserId() - : null - )) - .collect(Collectors.toList()); - } + return resolvePreparationDtos(schedule); } public List getAlarmWindowSchedules(Long userId, LocalDateTime startDate, LocalDateTime endDate) { @@ -344,13 +387,10 @@ public List getAlarmWindowSchedules(Long userId, LocalDa } List schedules = scheduleRepository.findAlarmWindowSchedules(userId, startDate, endDate, DoneStatus.NOT_ENDED); - List userPreparations = preparationUserRepository.findByUserIdWithNextPreparation(userId).stream() - .map(this::mapPreparationUserToDto) - .collect(Collectors.toList()); Integer defaultAlarmOffsetMinutes = alarmService.getDefaultAlarmOffsetMinutes(userId); return schedules.stream() - .map(schedule -> mapToAlarmWindowDto(schedule, userPreparations, defaultAlarmOffsetMinutes)) + .map(schedule -> mapToAlarmWindowDto(schedule, defaultAlarmOffsetMinutes)) .collect(Collectors.toList()); } @@ -366,16 +406,17 @@ private ScheduleDto mapToDto(Schedule schedule) { schedule.getLatenessTime(), schedule.getDoneStatus(), schedule.getStartedAt(), - schedule.getFinishedAt() + schedule.getFinishedAt(), + schedule.effectivePreparationMode(), + schedule.getPreparationTemplate() != null ? schedule.getPreparationTemplate().getPreparationTemplateId() : null, + schedule.getPreparationTemplate() != null ? schedule.getPreparationTemplate().getTemplateName() : null, + schedule.getPreparationTemplate() != null ? schedule.getPreparationTemplate().isDeleted() : false, + schedule.getStartedAt() != null ); } - private AlarmWindowScheduleDto mapToAlarmWindowDto(Schedule schedule, List userPreparations, Integer defaultAlarmOffsetMinutes) { - List preparations = schedule.getStartedAt() != null || Boolean.TRUE.equals(schedule.getIsChange()) - ? preparationScheduleRepository.findByScheduleWithNextPreparation(schedule).stream() - .map(this::mapPreparationScheduleToDto) - .collect(Collectors.toList()) - : userPreparations; + private AlarmWindowScheduleDto mapToAlarmWindowDto(Schedule schedule, Integer defaultAlarmOffsetMinutes) { + List preparations = resolvePreparationDtos(schedule); int totalPreparationTime = preparations.stream() .map(PreparationDto::getPreparationTime) @@ -398,6 +439,11 @@ private AlarmWindowScheduleDto mapToAlarmWindowDto(Schedule schedule, List resolvePreparationDtos(Schedule schedule) { + if (schedule.getStartedAt() != null || schedule.effectivePreparationMode() == PreparationMode.CUSTOM) { + return preparationStepService.toLinkedDtoFromSchedule( + preparationScheduleRepository.findByScheduleWithNextPreparation(schedule) + ); + } + if (schedule.effectivePreparationMode() == PreparationMode.TEMPLATE) { + if (schedule.getPreparationTemplate() == null) { + throw new GeneralException(PREPARATION_TEMPLATE_NOT_FOUND); + } + return preparationStepService.toLinkedDtoFromTemplate( + preparationTemplateStepRepository.findByPreparationTemplateOrdered(schedule.getPreparationTemplate()) + ); + } + return preparationStepService.toLinkedDtoFromUser( + preparationUserRepository.findByUserIdWithNextPreparation(schedule.getUser().getId()) + ); + } + + private void applyCreatePreparationMode(Schedule schedule, Long userId, ScheduleAddDto scheduleAddDto) { + boolean hasTemplate = scheduleAddDto.getPreparationTemplateId() != null; + boolean hasCustom = scheduleAddDto.getCustomPreparations() != null; + if (hasCustom && scheduleAddDto.getCustomPreparations().isEmpty()) { + throw new GeneralException(INVALID_INPUT); + } + if (hasTemplate && hasCustom) { + throw new GeneralException(INVALID_INPUT); + } + if (hasTemplate) { + schedule.useTemplatePreparation(findActiveTemplate(userId, scheduleAddDto.getPreparationTemplateId())); + } else if (hasCustom) { + schedule.useCustomPreparation(); + } else { + schedule.useDefaultPreparation(); + } + } + + private void applyModifyPreparationMode(Schedule schedule, Long userId, ScheduleModDto scheduleModDto) { + if (scheduleModDto.getPreparationMode() == null) { + if (scheduleModDto.getPreparationTemplateId() != null + || scheduleModDto.getCustomPreparations() != null) { + throw new GeneralException(INVALID_INPUT); + } + return; + } + + switch (scheduleModDto.getPreparationMode()) { + case DEFAULT -> { + if (scheduleModDto.getPreparationTemplateId() != null + || scheduleModDto.getCustomPreparations() != null) { + throw new GeneralException(INVALID_INPUT); + } + preparationScheduleRepository.deleteBySchedule(schedule); + preparationScheduleRepository.flush(); + schedule.useDefaultPreparation(); + } + case TEMPLATE -> { + if (scheduleModDto.getPreparationTemplateId() == null + || scheduleModDto.getCustomPreparations() != null) { + throw new GeneralException(INVALID_INPUT); + } + preparationScheduleRepository.deleteBySchedule(schedule); + preparationScheduleRepository.flush(); + schedule.useTemplatePreparation(findActiveTemplate(userId, scheduleModDto.getPreparationTemplateId())); + } + case CUSTOM -> { + if (scheduleModDto.getPreparationTemplateId() != null + || scheduleModDto.getCustomPreparations() == null + || scheduleModDto.getCustomPreparations().isEmpty()) { + throw new GeneralException(INVALID_INPUT); + } + schedule.useCustomPreparation(); + replaceSchedulePreparations(schedule, preparationStepService.normalizeOrdered(scheduleModDto.getCustomPreparations())); + } + } + } + + private PreparationTemplate findActiveTemplate(Long userId, UUID templateId) { + PreparationTemplate template = preparationTemplateRepository.findByIdAndUserId(templateId, userId) + .orElseThrow(() -> new GeneralException(PREPARATION_TEMPLATE_NOT_FOUND)); + if (template.isDeleted()) { + throw new GeneralException(PREPARATION_TEMPLATE_DELETED); + } + return template; + } + + public void refreshNotStartedTemplateModeSchedules(UUID templateId) { + scheduleRepository.findNotStartedTemplateModeSchedules(templateId) + .forEach(this::refreshScheduleNotification); + } + + public void refreshNotStartedDefaultModeSchedules(Long userId) { + scheduleRepository.findNotStartedDefaultModeSchedules(userId) + .forEach(this::refreshScheduleNotification); + } + + private void refreshScheduleNotification(Schedule schedule) { + NotificationSchedule notification = notificationScheduleRepository.findByScheduleScheduleId(schedule.getScheduleId()) + .orElseThrow(() -> new GeneralException(NOTIFICATION_NOT_FOUND)); + LocalDateTime newNotificationTime = getNotificationTime(schedule, schedule.getUser()); + if (newNotificationTime.equals(notification.getNotificationTime())) { + notificationService.cancelScheduledNotification(notification.getId()); + notification.markAsUnsent(); + notificationScheduleRepository.save(notification); + notificationService.scheduleReminder(notification); + return; + } + updateAndRescheduleNotification(newNotificationTime, notification); + } + private PreparationDto mapPreparationUserToDto(PreparationUser preparationUser) { return new PreparationDto( preparationUser.getPreparationUserId(), diff --git a/ontime-back/src/main/resources/db/migration/V14__add_preparation_templates_and_modes.sql b/ontime-back/src/main/resources/db/migration/V14__add_preparation_templates_and_modes.sql new file mode 100644 index 0000000..5f9b666 --- /dev/null +++ b/ontime-back/src/main/resources/db/migration/V14__add_preparation_templates_and_modes.sql @@ -0,0 +1,68 @@ +ALTER TABLE preparation_user + ADD COLUMN order_index INT NULL; + +ALTER TABLE preparation_schedule + ADD COLUMN order_index INT NULL; + +SET @prev_user_id := NULL; +SET @user_order := -1; +UPDATE preparation_user pu +JOIN ( + SELECT preparation_user_id, + user_id, + @user_order := IF(@prev_user_id = user_id, @user_order + 1, 0) AS computed_order, + @prev_user_id := user_id + FROM preparation_user + ORDER BY user_id, preparation_user_id +) ordered ON ordered.preparation_user_id = pu.preparation_user_id +SET pu.order_index = ordered.computed_order; + +SET @prev_schedule_id := NULL; +SET @schedule_order := -1; +UPDATE preparation_schedule ps +JOIN ( + SELECT preparation_schedule_id, + schedule_id, + @schedule_order := IF(@prev_schedule_id = schedule_id, @schedule_order + 1, 0) AS computed_order, + @prev_schedule_id := schedule_id + FROM preparation_schedule + ORDER BY schedule_id, preparation_schedule_id +) ordered ON ordered.preparation_schedule_id = ps.preparation_schedule_id +SET ps.order_index = ordered.computed_order; + +CREATE TABLE preparation_template ( + preparation_template_id BINARY(16) PRIMARY KEY, + user_id BIGINT NOT NULL, + template_name VARCHAR(30) NOT NULL, + normalized_template_name VARCHAR(30) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP NULL, + CONSTRAINT fk_preparation_template_user FOREIGN KEY (user_id) REFERENCES user (user_id) ON DELETE CASCADE +); + +CREATE INDEX idx_preparation_template_user_deleted ON preparation_template(user_id, deleted_at); +CREATE INDEX idx_preparation_template_created ON preparation_template(created_at); +CREATE UNIQUE INDEX uk_preparation_template_active_name ON preparation_template(user_id, normalized_template_name, deleted_at); + +CREATE TABLE preparation_template_step ( + preparation_template_step_id BINARY(16) PRIMARY KEY, + preparation_template_id BINARY(16) NOT NULL, + preparation_name VARCHAR(50) NOT NULL, + preparation_time INT NOT NULL, + order_index INT NOT NULL, + CONSTRAINT fk_preparation_template_step_template FOREIGN KEY (preparation_template_id) REFERENCES preparation_template (preparation_template_id) ON DELETE CASCADE +); + +CREATE INDEX idx_preparation_template_step_template_order ON preparation_template_step(preparation_template_id, order_index); + +ALTER TABLE schedule + ADD COLUMN preparation_mode VARCHAR(20) NOT NULL DEFAULT 'DEFAULT', + ADD COLUMN preparation_template_id BINARY(16) NULL, + ADD CONSTRAINT fk_schedule_preparation_template FOREIGN KEY (preparation_template_id) REFERENCES preparation_template (preparation_template_id) ON DELETE SET NULL; + +UPDATE schedule +SET preparation_mode = CASE + WHEN is_change = 1 THEN 'CUSTOM' + ELSE 'DEFAULT' +END; diff --git a/plans/multiple-preparation-templates-plan.md b/plans/multiple-preparation-templates-plan.md new file mode 100644 index 0000000..0eba91d --- /dev/null +++ b/plans/multiple-preparation-templates-plan.md @@ -0,0 +1,254 @@ +# Multiple Preparation Templates Plan + +## Goal +Add support for multiple user-defined preparation templates while preserving the existing fixed default preparation flow and current schedule state policy. + +Users should be able to: +- Keep one fixed default preparation set with no custom name. +- Create up to 20 active named preparation templates. +- Choose fixed default, a named template, or custom preparations when creating or updating a schedule. +- Soft-delete named templates without breaking schedules that already reference them. +- Keep started schedules frozen so later default/template edits do not mutate in-progress preparation data. + +## Context +The current backend has one fixed default preparation chain per user in `preparation_user`. Schedule-specific preparation chains live in `preparation_schedule`. + +Relevant code: +- `PreparationUserService` and `PreparationUserController` manage the fixed default preparations through `/preparations`. +- `PreparationScheduleService` and `PreparationScheduleController` manage schedule-specific preparations through `/schedules/{scheduleId}/preparations`. +- `ScheduleService` currently reads default preparations unless the schedule is started or `isChange = true`. +- `Schedule.startSchedule` freezes preparation data into `preparation_schedule` rows when a schedule starts. +- `ScheduleService.assertScheduleEditable` rejects edits after `startedAt != null` or after finish. +- `ScheduleService.deleteSchedule` allows deleting not-finished schedules, including started but unfinished schedules. + +Current state policy to preserve: +- Finished means `finishedAt != null` or `doneStatus != NOT_ENDED`. +- Started means `startedAt != null`. +- Schedule detail updates are allowed only when not finished and not started. +- Schedule preparation updates are allowed only when not finished and not started. +- Schedule deletion is allowed when not finished, even if started. +- Finish is allowed only when started and not finished. + +## Decisions +- Keep the fixed default preparation separate from named templates. It remains stored in `preparation_user`, has no custom name, and is managed by the existing `/preparations` endpoints. +- Add named preparation templates in new tables, separate from `preparation_user`. +- `GET /preparation-templates` returns only active named templates, not the fixed default. +- The fixed default is selected for schedule create when neither `preparationTemplateId` nor `customPreparations` is sent. +- Named templates use `deletedAt` soft delete. Deleted templates are hidden from the list, immutable, unavailable for new schedule selection, but still readable by direct ID for the owner. +- Existing schedules that reference a soft-deleted template keep that reference during ordinary schedule detail edits. +- Schedule metadata should include `preparationTemplateDeleted`, read live from the template row, so clients can show deleted linked templates as disabled/unavailable. +- Do not hard-delete soft-deleted templates while any schedule still references them. A future retention cleanup may hard-delete only unreferenced deleted templates. +- Account deletion should cascade through named templates, template steps, schedules, and schedule preparation snapshots. +- Privacy/account-deletion docs and tests should mention named preparation templates as preparation data. +- Soft-deleting a template does not reschedule notifications because existing linked schedules continue to resolve the same template data. +- Deleted template names are reusable by new active templates. +- Active template names are unique per user after trimming and case-insensitive normalization. +- Template names and preparation step names should be trimmed before validation/storage. +- Empty or whitespace-only template names are invalid. +- Duplicate preparation step names are allowed, including identical name/duration pairs. +- Active named templates are capped at 20 per user, excluding the fixed default. +- Each template/custom preparation list must contain 1 to 50 steps. +- Each step requires `1 <= preparationTime <= 1440`. +- Total preparation time per template/custom list must be at most 1440 minutes. +- Malformed ordered payloads, duplicate request step IDs, duplicate/gapped `orderIndex`, and malformed linked-list payloads should return `INVALID_INPUT`. +- Linked-list compatibility payloads must have exactly one head, no cycles, no disconnected nodes, no duplicate IDs, and `nextPreparationId` values only within the payload. +- New template APIs use ordered steps with zero-based contiguous `orderIndex`. +- Ordered payload arrays may arrive in any array order. Backend validates indexes, sorts by `orderIndex`, and returns sorted responses. +- Existing step IDs may be reused within the same default list, same template, or same custom schedule update, including when the step order changes. +- Client-provided preparation step UUIDs must be globally unique across all preparation step tables unless they already belong to the same resource being updated. +- Cross-table step ID collision checks can be service-level validation; table primary keys still enforce within-table uniqueness. +- Template IDs only need to be unique within the `preparation_template` table. Soft-deleted template IDs remain reserved and cannot be reused. +- Named template steps do not need legacy `nextPreparationId`. +- Existing fixed default and schedule-specific tables get `order_index`, but keep `next_preparation_id` temporarily. +- New writes to fixed default and schedule-specific preparations should maintain both `order_index` and temporary `next_preparation_id`. +- Existing compatibility endpoints keep accepting/returning linked-list-shaped `PreparationDto`. +- Compatibility responses synthesize `nextPreparationId` from order where needed. +- Bad legacy linked-list data should fail migration loudly rather than guessing order. +- Add explicit schedule preparation state: + - `preparationMode`: `DEFAULT`, `TEMPLATE`, or `CUSTOM`. + - `preparationTemplateId`: nullable, non-null only for `TEMPLATE`. + - `preparationTemplateDeleted`: true only when a `TEMPLATE` schedule references a soft-deleted template. + - `preparationFrozen`: computed as `startedAt != null`. +- Deprecate `isChange`; keep temporarily for migration/backward compatibility, but new behavior should use `preparationMode`. +- Schedule create infers mode: + - no `preparationTemplateId` and no `customPreparations`: `DEFAULT`. + - only `preparationTemplateId`: `TEMPLATE`. + - only `customPreparations`: `CUSTOM`. + - both present: reject. +- Schedule update changes preparation source only when `preparationMode` is explicitly sent: + - `DEFAULT`: no template ID, no custom list. + - `TEMPLATE`: requires active template ID, no custom list. + - `CUSTOM`: requires full custom list, no template ID. + - omitted: leave current preparation source unchanged. +- Schedule responses include metadata but not full steps in normal list/detail: + - `preparationMode` + - `preparationTemplateId` + - `preparationTemplateName` + - `preparationTemplateDeleted` + - `preparationFrozen` +- Schedule responses should expose both raw `startedAt`/`finishedAt` timestamps and the convenience `preparationFrozen` flag. +- `alarm-window` responses include the same metadata and continue to include full preparations. +- `StartScheduleResponseDto` inherits metadata through `ScheduleDto`. +- Started schedules keep their original `preparationMode`; they do not become `CUSTOM` just because snapshot rows exist. +- When a not-started schedule switches away from `CUSTOM`, delete old custom `preparation_schedule` rows. +- When a schedule customizes away from a template, clear `preparationTemplateId`. +- Template/default changes affect only schedules that are not finished and not started: + - `doneStatus = NOT_ENDED` + - `startedAt IS NULL` +- Past-notification behavior should be delegated to the existing notification scheduling logic rather than inventing a new policy. +- Started schedules are never mutated by default/template updates. +- Template name-only changes do not need notification timing recalculation. +- Step content/order/time changes should refresh affected notifications even if notification time is unchanged. +- Add a dedicated notification refresh/reschedule helper rather than relying only on the existing equal-time early return. +- Existing `isChange = true` schedules migrate to `CUSTOM` because old data cannot reliably distinguish user-custom rows from auto-snapshotted rows. +- Existing `isChange = false` schedules migrate to `DEFAULT`. +- Document the migration compromise for old started schedules that originally came from default but had `isChange = true`. +- Add template-specific error codes for not found, duplicate name or ID conflict, active limit exceeded, deleted template mutation/selection, and step ID conflict. +- Cross-user template IDs should behave as not found to avoid resource enumeration. +- Owned-but-deleted templates should be readable by direct detail endpoint, rejected for create/update schedule selection, rejected for update, and idempotently accepted for repeated delete. +- Selecting an owned deleted template should use a deleted-specific error; missing/cross-user templates should use not found. +- Template creation/update uses last-write-wins for the first implementation; do not add optimistic locking yet. +- Schedule create/update should validate active template status inside its transaction. If a template is deleted immediately after a schedule links to it, the schedule keeps the link and future reads show `preparationTemplateDeleted = true`. +- Templates should have `createdAt` and `updatedAt`, and soft delete should set both `deletedAt` and `updatedAt`. +- Template list ordering should be deterministic, preferably by `createdAt` ascending with a stable tiebreaker. +- Template steps do not need individual timestamps while steps are full-replaced. + +## Steps +1. Add ordering to existing preparation tables. + - Add `order_index` to `preparation_user`. + - Add `order_index` to `preparation_schedule`. + - Backfill by traversing each legacy `next_preparation_id` chain from its head. + - Fail migration if a chain has cycles, multiple heads, disconnected nodes, duplicate order, or invalid references. + - Keep `next_preparation_id` columns temporarily. + +2. Update fixed default preparation reads/writes. + - Update repositories to read `preparation_user` by `order_index`. + - Keep `/preparations` request/response as linked-list-shaped `PreparationDto`. + - Convert incoming linked-list payloads to contiguous order. + - Maintain temporary `next_preparation_id` links on writes. + - Validate step count, positive step durations, total duration, duplicate IDs, and ownership/collision rules. + +3. Update schedule-specific preparation reads/writes. + - Update repositories to read `preparation_schedule` by `order_index`. + - Keep old `/schedules/{scheduleId}/preparations` request/response shape. + - Treat old schedule-preparation POST/PUT as `CUSTOM` mode compatibility endpoints. + - Validate schedule editability before writing custom rows. + - Maintain temporary `next_preparation_id` links on writes. + +4. Add named template schema. + - Create `preparation_template` with client-provided UUID primary key, user FK, `template_name`, `created_at`, `updated_at`, and `deleted_at`. + - Create `preparation_template_step` with client-provided UUID primary key, template FK, name, time, and `order_index`. + - Add indexes for user/template lookup and ordered step reads. + - Enforce active template name uniqueness per user after trim/case normalization in service logic and DB support where practical. + - Enforce active template count limit of 20 per user. + +5. Add template DTOs, repository, service, and controller. + - `GET /preparation-templates`: active templates with full ordered step lists, deterministic ordering, `createdAt`, and `updatedAt`; omit `deletedAt`. + - `GET /preparation-templates/{templateId}`: direct owner lookup, including soft-deleted templates, full steps, `createdAt`, `updatedAt`, and `deletedAt`. + - `POST /preparation-templates`: create active named template with full ordered steps. + - `PUT /preparation-templates/{templateId}`: full replace of name and steps; reject deleted templates. + - `DELETE /preparation-templates/{templateId}`: soft delete; always allowed for owned named templates; repeated delete is idempotent; no notification changes. + - Trim template/step names and validate normalized active-name uniqueness. + - Reject duplicate request step IDs, cross-resource step ID collisions, non-contiguous order, and invalid durations. + +6. Add explicit schedule preparation mode. + - Add `preparation_mode` to `schedule`, required after migration. + - Add nullable `preparation_template_id` FK to named templates. + - Backfill `CUSTOM` for existing `is_change = true`. + - Backfill `DEFAULT` for existing `is_change = false` or null. + - Keep `is_change` temporarily but stop treating it as source of truth for new behavior. + +7. Update schedule create. + - Add `preparationTemplateId` and ordered `customPreparations` to create DTO. + - Reject payloads that include both. + - If neither is present, create `DEFAULT` schedule. + - If template ID is present, verify owner and active status in the transaction, then create `TEMPLATE` schedule. + - If custom preparations are present, create `CUSTOM` schedule and write `preparation_schedule` rows immediately. + - Recalculate and create notification from the selected source. + +8. Update schedule modify. + - Add optional `preparationMode`, `preparationTemplateId`, and ordered `customPreparations` to modify DTO. + - Preserve current source when `preparationMode` is omitted. + - For `DEFAULT`, clear template ID and delete pre-start custom rows. + - For `TEMPLATE`, require active owned template ID, clear custom rows, and set template ID. + - For `CUSTOM`, require full custom list, clear template ID, and replace custom rows. + - Reject mixed mode payloads. + - Preserve a soft-deleted template reference when only ordinary schedule details change and `preparationMode` is omitted. + - Allow schedules that reference deleted templates to switch normally to `DEFAULT`, an active `TEMPLATE`, or `CUSTOM`. + - Preserve existing started/finished edit restrictions. + - Refresh or reschedule notifications after source or step changes. + +9. Update preparation resolution. + - For not-started schedules: + - `DEFAULT`: read `preparation_user`. + - `TEMPLATE`: read named template steps, including soft-deleted referenced templates. + - `CUSTOM`: read `preparation_schedule`. + - For started schedules: + - Always read frozen `preparation_schedule` snapshot rows. + - Continue returning old linked-list-shaped `PreparationDto` from existing schedule preparation reads. + +10. Update start snapshot behavior. + - Preserve original `preparationMode`. + - For `DEFAULT`, delete any stale non-custom rows and snapshot fixed default into `preparation_schedule`. + - For `TEMPLATE`, delete any stale non-custom rows and snapshot the referenced template into `preparation_schedule`. + - For `CUSTOM`, leave existing custom rows unchanged. + - Set `startedAt` as today; `preparationFrozen` is computed from it. + +11. Update notification refresh/reschedule. + - Add a helper that recalculates notification time and can force refresh payloads when step content/order changes but time is unchanged. + - Use it after template step changes for affected `TEMPLATE` schedules where `doneStatus = NOT_ENDED` and `startedAt IS NULL`. + - Use it after fixed default step changes for affected `DEFAULT` schedules where `doneStatus = NOT_ENDED` and `startedAt IS NULL`. + - Use it after schedule custom preparation changes. + - Inspect `NotificationService` and native alarm status flow before choosing the exact payload refresh mechanism. + +12. Update response DTOs and API docs. + - Add `preparationMode`, `preparationTemplateId`, `preparationTemplateName`, `preparationTemplateDeleted`, and `preparationFrozen` to `ScheduleDto`. + - Add the same metadata to `AlarmWindowScheduleDto`. + - Ensure `preparationTemplateName` and `preparationTemplateDeleted` are read live from the template row, including soft-deleted references. + - Update Swagger examples for schedule create/update, schedule responses, alarm-window, and template endpoints. + +13. Update account deletion, privacy, and repair flows. + - Ensure account deletion cascades remove preparation templates and template steps. + - Update privacy policy/account deletion evidence to include named preparation templates. + - Adapt `repairStartedSchedulePreparationSnapshots()` to the new modes. + - For old migrated `CUSTOM` schedules without rows, report or handle carefully rather than guessing hidden source intent. + +14. Add tests. + - Migration/order backfill behavior where practical. + - Existing `/preparations` compatibility. + - Existing `/schedules/{id}/preparations` compatibility and `CUSTOM` mapping. + - Template CRUD, validation, active-name uniqueness after trim/case normalization, active cap, deterministic ordering, timestamps, soft delete, repeated delete idempotency, deleted detail lookup, and deleted update rejection. + - Cross-user template lookups return not found. + - Step ID collision rules across default, schedule custom, and template step tables. + - Schedule create modes and mixed payload rejection. + - Schedule update mode switching and custom row cleanup. + - Schedules referencing deleted templates preserve references on ordinary edits and can switch source normally. + - Started schedule freeze behavior for `DEFAULT`, `TEMPLATE`, and `CUSTOM`. + - Default/template updates affect only not-finished and not-started schedules. + - Step content changes refresh notifications even when notification time is unchanged. + - Existing delete/edit/finish/start schedule state policy remains intact. + - Account deletion cascades named templates and template steps. + +15. Plan later cleanup. + - Remove or fully ignore `is_change` after clients no longer depend on it. + - Remove `next_preparation_id` from fixed default and schedule-specific tables after compatibility endpoints migrate. + - Consider replacing old linked-list DTOs with ordered DTOs in a versioned API. + +## Validation +- Run the backend test suite: + - `./gradlew test` from `ontime-back/`. +- Run migration tests or a local Flyway migration against representative legacy data. +- Manually verify these API flows: + - Onboarding/default preparation still works through `/preparations`. + - Create/list/update/delete named templates. + - Create schedules in `DEFAULT`, `TEMPLATE`, and `CUSTOM` mode. + - Update a not-started schedule between modes. + - Start a schedule and confirm preparations freeze. + - Soft-delete a template and confirm existing linked schedules still resolve it, while new selection rejects it. + - Confirm schedules linked to deleted templates show `preparationTemplateDeleted = true`. + - Update default/template steps and confirm only not-started, not-finished schedules are refreshed. + +## Open Questions +- Exact notification payload refresh mechanism after equal-time step content changes. Inspect `NotificationService` and native alarm status flow before implementation. +- Whether DB-level partial uniqueness for active template names is available in the deployed MySQL version or should remain service-enforced with locking. +- Whether to introduce a versioned ordered preparation read endpoint for new clients, or keep only template APIs ordered for the first release.