diff --git a/backend-core/src/main/java/com/aichef/config/DatabaseSchemaRepair.java b/backend-core/src/main/java/com/aichef/config/DatabaseSchemaRepair.java new file mode 100644 index 0000000..77c314f --- /dev/null +++ b/backend-core/src/main/java/com/aichef/config/DatabaseSchemaRepair.java @@ -0,0 +1,27 @@ +package com.aichef.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DatabaseSchemaRepair { + + private final JdbcTemplate jdbcTemplate; + + @PostConstruct + public void ensureColumns() { + try { + jdbcTemplate.execute("ALTER TABLE meetings ADD COLUMN IF NOT EXISTS color VARCHAR(7)"); + log.info("Schema repair check complete: meetings.color"); + } catch (Exception e) { + log.warn("Schema repair failed for meetings.color: {}", e.getMessage()); + } + } +} + diff --git a/backend-core/src/main/java/com/aichef/config/TelegramProperties.java b/backend-core/src/main/java/com/aichef/config/TelegramProperties.java index 1360825..b31d5c7 100644 --- a/backend-core/src/main/java/com/aichef/config/TelegramProperties.java +++ b/backend-core/src/main/java/com/aichef/config/TelegramProperties.java @@ -12,6 +12,7 @@ public record TelegramProperties( @NotBlank String webhookSecret, @NotBlank String webhookPath, @NotBlank String apiBase, - String publicBaseUrl + String publicBaseUrl, + boolean useWebhook ) { } diff --git a/backend-core/src/main/java/com/aichef/config/TelegramWebhookRegistrar.java b/backend-core/src/main/java/com/aichef/config/TelegramWebhookRegistrar.java index 60723b3..047720a 100644 --- a/backend-core/src/main/java/com/aichef/config/TelegramWebhookRegistrar.java +++ b/backend-core/src/main/java/com/aichef/config/TelegramWebhookRegistrar.java @@ -20,13 +20,21 @@ public class TelegramWebhookRegistrar implements ApplicationRunner { @Override public void run(ApplicationArguments args) { String baseUrl = properties.publicBaseUrl(); - log.info("Telegram config: botUsername={}, webhookPath={}, hasPublicBaseUrl={}, hasToken={}", + log.info("Telegram config: botUsername={}, webhookPath={}, hasPublicBaseUrl={}, hasToken={}, useWebhook={}", properties.botUsername(), properties.webhookPath(), baseUrl != null && !baseUrl.isBlank(), - properties.botToken() != null && !properties.botToken().isBlank()); + properties.botToken() != null && !properties.botToken().isBlank(), + properties.useWebhook()); telegramBotService.configureMiniAppEntryPoints(); + if (!properties.useWebhook()) { + log.info("Webhook is disabled by TELEGRAM_USE_WEBHOOK=false. Deleting webhook and enabling polling."); + telegramBotService.deleteWebhook(false); + telegramBotService.logWebhookInfo(); + return; + } + String webhookBaseUrl = resolveWebhookBaseUrl(baseUrl); if (webhookBaseUrl == null) { log.info("Webhook is disabled for current APP_PUBLIC_BASE_URL. Local mode: deleting webhook and enabling polling."); diff --git a/backend-core/src/main/java/com/aichef/controller/MiniAppMeController.java b/backend-core/src/main/java/com/aichef/controller/MiniAppMeController.java index dfa9d23..0fe6db8 100644 --- a/backend-core/src/main/java/com/aichef/controller/MiniAppMeController.java +++ b/backend-core/src/main/java/com/aichef/controller/MiniAppMeController.java @@ -2,17 +2,23 @@ import com.aichef.domain.enums.Gender; import com.aichef.domain.model.User; +import com.aichef.domain.model.UserGoogleConnection; +import com.aichef.repository.UserRepository; +import com.aichef.repository.UserGoogleConnectionRepository; import com.aichef.service.MiniAppAuthService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.time.ZoneId; import java.util.Optional; import java.util.UUID; @@ -23,6 +29,8 @@ public class MiniAppMeController { private final MiniAppAuthService miniAppAuthService; + private final UserRepository userRepository; + private final UserGoogleConnectionRepository userGoogleConnectionRepository; @GetMapping("/me") public ResponseEntity me( @@ -40,7 +48,44 @@ public ResponseEntity me( Gender gender = user.getGender() == null ? Gender.UNKNOWN : user.getGender(); log.info("MiniApp profile loaded. userId={}, telegramId={}, gender={}", user.getId(), user.getTelegramId(), gender); - return ResponseEntity.ok(new MeResponse(user.getId(), user.getTelegramId(), gender, toTitlePrefix(gender))); + UserGoogleConnection conn = userGoogleConnectionRepository.findByUser(user).orElse(null); + boolean googleConnected = conn != null + && ((conn.getRefreshToken() != null && !conn.getRefreshToken().isBlank()) + || (conn.getAccessToken() != null && !conn.getAccessToken().isBlank())); + boolean icsConnected = conn != null && conn.getIcsToken() != null && !conn.getIcsToken().isBlank(); + return ResponseEntity.ok(new MeResponse( + user.getId(), + user.getTelegramId(), + gender, + toTitlePrefix(gender), + user.getTimezone(), + new CalendarConnections(true, googleConnected, icsConnected) + )); + } + + @PatchMapping("/me/timezone") + public ResponseEntity updateTimezone( + @RequestBody TimezoneUpdateRequest request, + @RequestHeader(value = "X-Telegram-Init-Data", required = false) String initData, + @RequestParam(value = "telegramId", required = false) Long telegramId + ) { + Optional userOpt = miniAppAuthService.resolveUser(initData, telegramId); + if (userOpt.isEmpty()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Unauthorized"); + } + String timezone = request == null || request.timezone() == null ? "" : request.timezone().trim(); + if (timezone.isBlank()) { + return ResponseEntity.badRequest().body("timezone is required"); + } + try { + ZoneId.of(timezone); + } catch (Exception e) { + return ResponseEntity.badRequest().body("Invalid timezone"); + } + User user = userOpt.get(); + user.setTimezone(timezone); + userRepository.save(user); + return ResponseEntity.ok(new TimezoneUpdateResponse(timezone)); } private String toTitlePrefix(Gender gender) { @@ -50,6 +95,26 @@ private String toTitlePrefix(Gender gender) { return "Mr"; } - public record MeResponse(UUID id, Long telegramId, Gender gender, String titlePrefix) { + public record MeResponse( + UUID id, + Long telegramId, + Gender gender, + String titlePrefix, + String timezone, + CalendarConnections calendars + ) { + } + + public record CalendarConnections( + boolean internal, + boolean google, + boolean ical + ) { + } + + public record TimezoneUpdateRequest(String timezone) { + } + + public record TimezoneUpdateResponse(String timezone) { } } diff --git a/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java b/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java index 3f54977..ef1f646 100644 --- a/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java +++ b/backend-core/src/main/java/com/aichef/controller/MiniAppMeetingController.java @@ -1,18 +1,36 @@ package com.aichef.controller; import com.aichef.domain.enums.MeetingStatus; +import com.aichef.domain.enums.RelatedType; import com.aichef.domain.model.CalendarDay; import com.aichef.domain.model.Meeting; +import com.aichef.domain.model.Notification; import com.aichef.domain.model.User; import com.aichef.repository.CalendarDayRepository; import com.aichef.repository.MeetingRepository; +import com.aichef.repository.NotificationRepository; +import com.aichef.service.GoogleCalendarService; +import com.aichef.service.GoogleOAuthService; import com.aichef.service.MiniAppAuthService; import com.aichef.util.TextNormalization; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import java.time.LocalDate; import java.time.OffsetDateTime; @@ -20,16 +38,24 @@ import java.util.List; import java.util.Optional; import java.util.UUID; +import java.util.regex.Pattern; @RestController @Slf4j @RequiredArgsConstructor @RequestMapping("/api/miniapp/meetings") public class MiniAppMeetingController { + private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("^#([0-9a-fA-F]{6})$"); + private static final String DEFAULT_MEETING_COLOR = "#93c5fd"; + private static final int DEFAULT_REMINDER_MINUTES_BEFORE = 30; + private static final int MAX_REMINDER_MINUTES_BEFORE = 7 * 24 * 60; private final MiniAppAuthService miniAppAuthService; private final MeetingRepository meetingRepository; private final CalendarDayRepository calendarDayRepository; + private final NotificationRepository notificationRepository; + private final GoogleCalendarService googleCalendarService; + private final GoogleOAuthService googleOAuthService; @GetMapping public ResponseEntity list( @@ -72,6 +98,18 @@ public ResponseEntity create( || request.startsAt() == null || request.endsAt() == null) { return ResponseEntity.badRequest().body("Missing required fields"); } + if (!request.endsAt().isAfter(request.startsAt())) { + return ResponseEntity.badRequest().body("endsAt must be after startsAt"); + } + Integer reminderMinutesBefore = resolveReminderMinutes(request.reminderMinutesBefore(), null); + if (reminderMinutesBefore == null) { + return ResponseEntity.badRequest().body("Invalid reminderMinutesBefore"); + } + String normalizedColor = normalizeHexColor(request.color()); + if (request.color() != null && normalizedColor == null) { + return ResponseEntity.badRequest().body("Invalid color"); + } + User user = userOpt.get(); Meeting meeting = new Meeting(); meeting.setTitle(TextNormalization.normalizeRussian(request.title().trim())); @@ -79,30 +117,92 @@ public ResponseEntity create( meeting.setEndsAt(request.endsAt()); meeting.setLocation(request.location()); meeting.setExternalLink(request.externalLink()); + meeting.setColor(normalizedColor == null ? DEFAULT_MEETING_COLOR : normalizedColor); + meeting.setReminderMinutesBefore(reminderMinutesBefore); meeting.setStatus(MeetingStatus.CONFIRMED); meeting.setCalendarDay(getOrCreateDay(user, request.startsAt().toLocalDate())); - meetingRepository.save(meeting); + try { + meetingRepository.save(meeting); + } catch (DataIntegrityViolationException e) { + log.error("MiniApp meeting create DB constraint error. userId={}, telegramId={}, title={}, startsAt={}, endsAt={}, color={}, error={}", + user.getId(), user.getTelegramId(), request.title(), request.startsAt(), request.endsAt(), normalizedColor, e.getMessage(), e); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("DB constraint error"); + } catch (Exception e) { + log.error("MiniApp meeting create failed. userId={}, telegramId={}, title={}, startsAt={}, endsAt={}, color={}, error={}", + user.getId(), user.getTelegramId(), request.title(), request.startsAt(), request.endsAt(), normalizedColor, e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Internal error"); + } + + if (googleOAuthService.isConnected(user)) { + GoogleCalendarService.CreatedGoogleEvent googleEvent = googleCalendarService.createEvent( + user, + meeting.getTitle(), + meeting.getStartsAt(), + meeting.getEndsAt(), + meeting.getExternalLink(), + resolveZone(user) + ); + if (googleEvent != null) { + if (googleEvent.eventId() != null && !googleEvent.eventId().isBlank()) { + meeting.setGoogleEventId(googleEvent.eventId()); + } + if ((meeting.getExternalLink() == null || meeting.getExternalLink().isBlank()) + && googleEvent.htmlLink() != null && !googleEvent.htmlLink().isBlank()) { + meeting.setExternalLink(googleEvent.htmlLink()); + } + meetingRepository.save(meeting); + } + } + + rescheduleMeetingNotifications(user, meeting); + log.info("MiniApp meeting created. userId={}, telegramId={}, meetingId={}, startsAt={}, endsAt={}, color={}, reminderMinutesBefore={}", + user.getId(), user.getTelegramId(), meeting.getId(), meeting.getStartsAt(), meeting.getEndsAt(), meeting.getColor(), meeting.getReminderMinutesBefore()); return ResponseEntity.ok(MeetingDto.from(meeting)); } @PatchMapping("/{id}") + public ResponseEntity patch( + @PathVariable("id") UUID id, + @RequestBody MeetingUpdateRequest request, + @RequestHeader(value = "X-Telegram-Init-Data", required = false) String initData, + @RequestParam(value = "telegramId", required = false) Long telegramId + ) { + return updateInternal(id, request, initData, telegramId); + } + + @PutMapping("/{id}") public ResponseEntity update( @PathVariable("id") UUID id, @RequestBody MeetingUpdateRequest request, @RequestHeader(value = "X-Telegram-Init-Data", required = false) String initData, @RequestParam(value = "telegramId", required = false) Long telegramId + ) { + return updateInternal(id, request, initData, telegramId); + } + + private ResponseEntity updateInternal( + UUID id, + MeetingUpdateRequest request, + String initData, + Long telegramId ) { Optional userOpt = miniAppAuthService.resolveUser(initData, telegramId); if (userOpt.isEmpty()) { + log.warn("MiniApp meeting update unauthorized. meetingId={}, telegramIdParam={}, hasInitData={}", + id, telegramId, initData != null && !initData.isBlank()); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Unauthorized"); } User user = userOpt.get(); - Meeting meeting = meetingRepository.findById(id).orElse(null); - if (meeting == null || meeting.getCalendarDay() == null - || meeting.getCalendarDay().getUser() == null - || !meeting.getCalendarDay().getUser().getId().equals(user.getId())) { + Meeting meeting = meetingRepository.findByIdAndCalendarDay_User(id, user).orElse(null); + if (meeting == null) { + log.warn("MiniApp meeting update not found/forbidden. meetingId={}, userId={}, telegramId={}", + id, user.getId(), user.getTelegramId()); return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Meeting not found"); } + String normalizedColor = normalizeHexColor(request.color()); + if (request.color() != null && normalizedColor == null) { + return ResponseEntity.badRequest().body("Invalid color"); + } if (request.title() != null) { String title = TextNormalization.normalizeRussian(request.title().trim()); @@ -123,7 +223,85 @@ public ResponseEntity update( if (request.externalLink() != null) { meeting.setExternalLink(request.externalLink()); } - meetingRepository.save(meeting); + if (request.color() != null) { + meeting.setColor(normalizedColor); + } + if (request.reminderMinutesBefore() != null) { + Integer reminderMinutesBefore = resolveReminderMinutes(request.reminderMinutesBefore(), meeting.getReminderMinutesBefore()); + if (reminderMinutesBefore == null) { + return ResponseEntity.badRequest().body("Invalid reminderMinutesBefore"); + } + meeting.setReminderMinutesBefore(reminderMinutesBefore); + } else if (meeting.getReminderMinutesBefore() == null) { + meeting.setReminderMinutesBefore(DEFAULT_REMINDER_MINUTES_BEFORE); + } + + OffsetDateTime startsAt = meeting.getStartsAt(); + OffsetDateTime endsAt = meeting.getEndsAt(); + if (startsAt == null || endsAt == null || !endsAt.isAfter(startsAt)) { + return ResponseEntity.badRequest().body("endsAt must be after startsAt"); + } + try { + meetingRepository.save(meeting); + } catch (DataIntegrityViolationException e) { + log.error("MiniApp meeting update DB constraint error. userId={}, telegramId={}, meetingId={}, startsAt={}, endsAt={}, color={}, error={}", + user.getId(), user.getTelegramId(), meeting.getId(), meeting.getStartsAt(), meeting.getEndsAt(), meeting.getColor(), e.getMessage(), e); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("DB constraint error"); + } catch (Exception e) { + log.error("MiniApp meeting update failed. userId={}, telegramId={}, meetingId={}, startsAt={}, endsAt={}, color={}, error={}", + user.getId(), user.getTelegramId(), meeting.getId(), meeting.getStartsAt(), meeting.getEndsAt(), meeting.getColor(), e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Internal error"); + } + + if (googleOAuthService.isConnected(user)) { + ZoneId zone = resolveZone(user); + GoogleCalendarService.CreatedGoogleEvent googleEvent = null; + String googleEventId = meeting.getGoogleEventId(); + if (googleEventId != null && !googleEventId.isBlank()) { + googleEvent = googleCalendarService.updateEvent( + user, + googleEventId, + meeting.getTitle(), + meeting.getStartsAt(), + meeting.getEndsAt(), + meeting.getExternalLink(), + zone + ); + } + if (googleEvent == null) { + googleEvent = googleCalendarService.createEvent( + user, + meeting.getTitle(), + meeting.getStartsAt(), + meeting.getEndsAt(), + meeting.getExternalLink(), + zone + ); + } + if (googleEvent != null) { + boolean changed = false; + if (googleEvent.eventId() != null && !googleEvent.eventId().isBlank() + && (meeting.getGoogleEventId() == null || !meeting.getGoogleEventId().equals(googleEvent.eventId()))) { + meeting.setGoogleEventId(googleEvent.eventId()); + changed = true; + } + if (googleEvent.htmlLink() != null && !googleEvent.htmlLink().isBlank() + && (meeting.getExternalLink() == null || meeting.getExternalLink().isBlank())) { + meeting.setExternalLink(googleEvent.htmlLink()); + changed = true; + } + if (changed) { + meetingRepository.save(meeting); + } + } else { + log.warn("MiniApp meeting update synced only in DB. Google sync failed. userId={}, telegramId={}, meetingId={}, googleEventId={}", + user.getId(), user.getTelegramId(), meeting.getId(), meeting.getGoogleEventId()); + } + } + + rescheduleMeetingNotifications(user, meeting); + log.info("MiniApp meeting updated. userId={}, telegramId={}, meetingId={}, startsAt={}, endsAt={}, color={}, reminderMinutesBefore={}", + user.getId(), user.getTelegramId(), meeting.getId(), meeting.getStartsAt(), meeting.getEndsAt(), meeting.getColor(), meeting.getReminderMinutesBefore()); return ResponseEntity.ok(MeetingDto.from(meeting)); } @@ -138,14 +316,21 @@ public ResponseEntity delete( return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Unauthorized"); } User user = userOpt.get(); - Meeting meeting = meetingRepository.findById(id).orElse(null); - if (meeting == null || meeting.getCalendarDay() == null - || meeting.getCalendarDay().getUser() == null - || !meeting.getCalendarDay().getUser().getId().equals(user.getId())) { + Meeting meeting = meetingRepository.findByIdAndCalendarDay_User(id, user).orElse(null); + if (meeting == null) { return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Meeting not found"); } meeting.setStatus(MeetingStatus.CANCELED); - meetingRepository.save(meeting); + try { + meetingRepository.save(meeting); + notificationRepository.deleteByRelatedTypeAndRelatedId(RelatedType.MEETING, meeting.getId()); + } catch (Exception e) { + log.error("MiniApp meeting delete failed. userId={}, telegramId={}, meetingId={}, error={}", + user.getId(), user.getTelegramId(), meeting.getId(), e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Internal error"); + } + log.info("MiniApp meeting deleted(canceled). userId={}, telegramId={}, meetingId={}", + user.getId(), user.getTelegramId(), meeting.getId()); return ResponseEntity.noContent().build(); } @@ -160,13 +345,58 @@ private CalendarDay getOrCreateDay(User user, LocalDate dayDate) { }); } + private void rescheduleMeetingNotifications(User user, Meeting meeting) { + if (user == null || meeting == null || meeting.getId() == null || meeting.getStartsAt() == null) { + return; + } + notificationRepository.deleteByRelatedTypeAndRelatedId(RelatedType.MEETING, meeting.getId()); + OffsetDateTime now = OffsetDateTime.now(); + OffsetDateTime startNotifyAt = meeting.getStartsAt().isAfter(now) + ? meeting.getStartsAt() + : now.plusSeconds(5); + scheduleMeetingNotification(user, meeting, startNotifyAt); + Integer reminderMinutesBefore = resolveReminderMinutes(meeting.getReminderMinutesBefore(), null); + if (reminderMinutesBefore != null && reminderMinutesBefore > 0) { + OffsetDateTime beforeNotifyAt = meeting.getStartsAt().minusMinutes(reminderMinutesBefore); + if (beforeNotifyAt.isAfter(now) && !beforeNotifyAt.isEqual(startNotifyAt)) { + scheduleMeetingNotification(user, meeting, beforeNotifyAt); + } + } + } + + private void scheduleMeetingNotification(User user, Meeting meeting, OffsetDateTime notifyAt) { + if (notifyAt == null) { + return; + } + Notification notification = new Notification(); + notification.setUser(user); + notification.setRelatedType(RelatedType.MEETING); + notification.setRelatedId(meeting.getId()); + notification.setNotifyAt(notifyAt); + notification.setSent(false); + notificationRepository.save(notification); + } + + private Integer resolveReminderMinutes(Integer value, Integer fallback) { + if (value == null) { + return fallback == null ? DEFAULT_REMINDER_MINUTES_BEFORE : fallback; + } + if (value < 0 || value > MAX_REMINDER_MINUTES_BEFORE) { + return null; + } + return value; + } + public record MeetingDto( UUID id, String title, OffsetDateTime startsAt, OffsetDateTime endsAt, String location, - String externalLink + String externalLink, + String color, + String googleEventId, + Integer reminderMinutesBefore ) { public static MeetingDto from(Meeting meeting) { return new MeetingDto( @@ -175,7 +405,10 @@ public static MeetingDto from(Meeting meeting) { meeting.getStartsAt(), meeting.getEndsAt(), TextNormalization.normalizeRussian(meeting.getLocation()), - TextNormalization.normalizeRussian(meeting.getExternalLink()) + TextNormalization.normalizeRussian(meeting.getExternalLink()), + TextNormalization.normalizeRussian(meeting.getColor()), + TextNormalization.normalizeRussian(meeting.getGoogleEventId()), + meeting.getReminderMinutesBefore() ); } } @@ -185,7 +418,43 @@ public record MeetingUpdateRequest( OffsetDateTime startsAt, OffsetDateTime endsAt, String location, - String externalLink + String externalLink, + String color, + Integer reminderMinutesBefore ) { } + + private String normalizeHexColor(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + if (trimmed.isBlank()) { + return null; + } + return HEX_COLOR_PATTERN.matcher(trimmed).matches() ? trimmed.toLowerCase() : null; + } + + private ZoneId resolveZone(User user) { + try { + if (user == null || user.getTimezone() == null || user.getTimezone().isBlank()) { + return ZoneId.of("Europe/Moscow"); + } + return ZoneId.of(user.getTimezone()); + } catch (Exception ignored) { + return ZoneId.of("Europe/Moscow"); + } + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleTypeMismatch(MethodArgumentTypeMismatchException e) { + log.error("MiniApp meeting request type mismatch. message={}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid request format"); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnhandled(Exception e) { + log.error("MiniApp meeting controller unhandled error. message={}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Internal error"); + } } diff --git a/backend-core/src/main/java/com/aichef/controller/MiniAppNoteController.java b/backend-core/src/main/java/com/aichef/controller/MiniAppNoteController.java index cae561d..2662299 100644 --- a/backend-core/src/main/java/com/aichef/controller/MiniAppNoteController.java +++ b/backend-core/src/main/java/com/aichef/controller/MiniAppNoteController.java @@ -59,15 +59,15 @@ public ResponseEntity create( return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Unauthorized"); } - if (request.title() == null || request.title().isBlank() - || request.content() == null || request.content().isBlank()) { + if (request.title() == null || request.title().isBlank()) { return ResponseEntity.badRequest().body("Missing required fields"); } Note note = new Note(); note.setUser(userOpt.get()); note.setTitle(TextNormalization.normalizeRussian(request.title().trim())); - note.setContent(TextNormalization.normalizeRussian(request.content().trim())); + String rawContent = request.content() == null ? "" : request.content(); + note.setContent(TextNormalization.normalizeRussian(rawContent.trim())); note.setArchived(false); noteRepository.save(note); @@ -77,6 +77,25 @@ public ResponseEntity create( public record NoteCreateRequest(String title, String content) { } + @DeleteMapping("/{id}") + public ResponseEntity delete( + @PathVariable("id") UUID id, + @RequestHeader(value = "X-Telegram-Init-Data", required = false) String initData, + @RequestParam(value = "telegramId", required = false) Long telegramId + ) { + Optional userOpt = miniAppAuthService.resolveUser(initData, telegramId); + if (userOpt.isEmpty()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Unauthorized"); + } + Note note = noteRepository.findByIdAndUser(id, userOpt.get()).orElse(null); + if (note == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Note not found"); + } + note.setArchived(true); + noteRepository.save(note); + return ResponseEntity.noContent().build(); + } + public record NoteDto( UUID id, String title, diff --git a/backend-core/src/main/java/com/aichef/controller/MiniAppTaskController.java b/backend-core/src/main/java/com/aichef/controller/MiniAppTaskController.java index 22f792c..4df6768 100644 --- a/backend-core/src/main/java/com/aichef/controller/MiniAppTaskController.java +++ b/backend-core/src/main/java/com/aichef/controller/MiniAppTaskController.java @@ -136,6 +136,9 @@ public ResponseEntity update( if (request.completed() != null) { task.setCompleted(request.completed()); } + if (Boolean.TRUE.equals(request.clearDueAt())) { + task.setDueAt(null); + } if (request.dueAt() != null) { task.setDueAt(request.dueAt()); LocalDate day = request.dueAt().atZoneSameInstant(DEFAULT_ZONE).toLocalDate(); @@ -191,7 +194,7 @@ private CalendarDay getOrCreateDay(User user, LocalDate dayDate) { public record TaskCreateRequest(String title, String priority, OffsetDateTime dueAt) { } - public record TaskUpdateRequest(String title, String priority, Boolean completed, OffsetDateTime dueAt) { + public record TaskUpdateRequest(String title, String priority, Boolean completed, OffsetDateTime dueAt, Boolean clearDueAt) { } public record TaskDto( diff --git a/backend-core/src/main/java/com/aichef/domain/model/Meeting.java b/backend-core/src/main/java/com/aichef/domain/model/Meeting.java index a03a5f0..97a841d 100644 --- a/backend-core/src/main/java/com/aichef/domain/model/Meeting.java +++ b/backend-core/src/main/java/com/aichef/domain/model/Meeting.java @@ -42,9 +42,15 @@ public class Meeting extends BaseEntity { @Column(name = "external_link") private String externalLink; + @Column(name = "color") + private String color; + @Column(name = "google_event_id") private String googleEventId; + @Column(name = "reminder_minutes_before") + private Integer reminderMinutesBefore; + @Enumerated(EnumType.STRING) @Column(nullable = false) private MeetingStatus status = MeetingStatus.CONFIRMED; diff --git a/backend-core/src/main/java/com/aichef/repository/MeetingRepository.java b/backend-core/src/main/java/com/aichef/repository/MeetingRepository.java index 6535704..e764ea8 100644 --- a/backend-core/src/main/java/com/aichef/repository/MeetingRepository.java +++ b/backend-core/src/main/java/com/aichef/repository/MeetingRepository.java @@ -6,10 +6,13 @@ import java.time.LocalDate; import java.util.List; +import java.util.Optional; import java.util.UUID; public interface MeetingRepository extends JpaRepository { List findByCalendarDay_UserAndCalendarDay_DayDateBetweenOrderByStartsAtAsc(User user, LocalDate from, LocalDate to); List findByCalendarDay_UserOrderByStartsAtAsc(User user); + + Optional findByIdAndCalendarDay_User(UUID id, User user); } diff --git a/backend-core/src/main/java/com/aichef/repository/NotificationRepository.java b/backend-core/src/main/java/com/aichef/repository/NotificationRepository.java index 234f989..de4a46b 100644 --- a/backend-core/src/main/java/com/aichef/repository/NotificationRepository.java +++ b/backend-core/src/main/java/com/aichef/repository/NotificationRepository.java @@ -1,7 +1,10 @@ package com.aichef.repository; import com.aichef.domain.model.Notification; +import com.aichef.domain.enums.RelatedType; +import jakarta.transaction.Transactional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import java.time.OffsetDateTime; import java.util.List; @@ -9,4 +12,8 @@ public interface NotificationRepository extends JpaRepository { List findTop100BySentFalseAndNotifyAtLessThanEqualOrderByNotifyAtAsc(OffsetDateTime now); + + @Modifying + @Transactional + void deleteByRelatedTypeAndRelatedId(RelatedType relatedType, UUID relatedId); } diff --git a/backend-core/src/main/java/com/aichef/service/GoogleCalendarService.java b/backend-core/src/main/java/com/aichef/service/GoogleCalendarService.java index facabac..aee0391 100644 --- a/backend-core/src/main/java/com/aichef/service/GoogleCalendarService.java +++ b/backend-core/src/main/java/com/aichef/service/GoogleCalendarService.java @@ -227,6 +227,113 @@ public CreatedGoogleEvent createEvent(User user, String title, OffsetDateTime st } } + public CreatedGoogleEvent updateEvent( + User user, + String eventId, + String title, + OffsetDateTime startsAt, + OffsetDateTime endsAt, + String externalLink, + ZoneId zoneId + ) { + if (eventId == null || eventId.isBlank()) { + return null; + } + String calendarId = resolveCalendarId(user); + String accessToken = resolveAccessToken(user); + if (calendarId == null || accessToken == null) { + log.warn( + "Skip Google Calendar update: missing calendarId or accessToken. userId={}, hasCalendarId={}, hasAccessToken={}, eventId={}", + user == null ? null : user.getId(), + calendarId != null && !calendarId.isBlank(), + accessToken != null && !accessToken.isBlank(), + eventId + ); + return null; + } + + try { + RestClient client = RestClient.builder().baseUrl(properties.safeApiBase()).build(); + log.info( + "Google Calendar update requested. userId={}, calendarId={}, eventId={}, title={}, startsAt={}, endsAt={}, zone={}", + user == null ? null : user.getId(), + calendarId, + eventId, + title, + startsAt, + endsAt, + zoneId == null ? null : zoneId.getId() + ); + + Map payload = new HashMap<>(); + payload.put("summary", title); + payload.put("description", externalLink); + payload.put("start", Map.of( + "dateTime", startsAt.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), + "timeZone", zoneId.getId() + )); + payload.put("end", Map.of( + "dateTime", endsAt.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), + "timeZone", zoneId.getId() + )); + + Map response = client.patch() + .uri("/calendars/{calendarId}/events/{eventId}", calendarId, eventId) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve() + .body(Map.class); + + if (response == null) { + log.warn( + "Google Calendar update returned empty response. userId={}, calendarId={}, eventId={}", + user == null ? null : user.getId(), + calendarId, + eventId + ); + return new CreatedGoogleEvent(eventId, null); + } + Object responseEventId = response.get("id"); + Object htmlLink = response.get("htmlLink"); + String updatedId = responseEventId instanceof String s ? s : eventId; + String link = htmlLink instanceof String s ? s : null; + log.info( + "Google Calendar update succeeded. userId={}, calendarId={}, eventId={}, htmlLink={}", + user == null ? null : user.getId(), + calendarId, + updatedId, + link + ); + return new CreatedGoogleEvent(updatedId, link); + } catch (RestClientResponseException e) { + log.error( + "Google Calendar update failed. status={}, userId={}, calendarId={}, eventId={}, title={}, startsAt={}, endsAt={}, body={}", + e.getStatusCode(), + user == null ? null : user.getId(), + calendarId, + eventId, + title, + startsAt, + endsAt, + e.getResponseBodyAsString() + ); + return null; + } catch (Exception e) { + log.error( + "Failed to update Google Calendar event. userId={}, calendarId={}, eventId={}, title={}, startsAt={}, endsAt={}, error={}", + user == null ? null : user.getId(), + calendarId, + eventId, + title, + startsAt, + endsAt, + e.getMessage() + ); + return null; + } + } + public record CreatedGoogleEvent(String eventId, String htmlLink) { } diff --git a/backend-core/src/main/java/com/aichef/service/MessageUnderstandingService.java b/backend-core/src/main/java/com/aichef/service/MessageUnderstandingService.java index 413461a..2b97d5a 100644 --- a/backend-core/src/main/java/com/aichef/service/MessageUnderstandingService.java +++ b/backend-core/src/main/java/com/aichef/service/MessageUnderstandingService.java @@ -190,6 +190,47 @@ public MessageIntent decide(String sourceText, ZoneId zoneId) { ); } + if (llmParsed.isCreateTaskIntent()) { + OffsetDateTime dueAt = inferTaskDue(normalized, zoneId); + String title = llmParsed.title() == null || llmParsed.title().isBlank() + ? cleanupTaskTitle(text) + : cleanupTaskTitle(llmParsed.title()); + return new MessageIntent( + BotAction.CREATE_TASK, + FilterClassification.TASK, + InboundStatus.PROCESSED, + title, + PriorityLevel.MEDIUM, + null, + null, + dueAt, + null, + null, + null, + link, + "✅ Задача добавлена: " + title + ); + } + + if (llmParsed.isCreateNoteIntent()) { + String noteTitle = cleanupTitle(stripTaskCommandPhrases(stripCreateCommandPhrases(text)), "Заметка"); + return new MessageIntent( + BotAction.CREATE_NOTE, + FilterClassification.INFO_ONLY, + InboundStatus.PROCESSED, + noteTitle, + PriorityLevel.LOW, + null, + null, + null, + null, + null, + text, + link, + "📝 Сохранил как заметку." + ); + } + if (hasMeetingHint) { OffsetDateTime start = inferMeetingStart(normalized, zoneId); OffsetDateTime end = start.plusHours(1); @@ -213,7 +254,7 @@ public MessageIntent decide(String sourceText, ZoneId zoneId) { if (hasTaskHint) { OffsetDateTime dueAt = inferTaskDue(normalized, zoneId); - String title = cleanupTitle(text, "Задача"); + String title = cleanupTaskTitle(text); return new MessageIntent( BotAction.CREATE_TASK, FilterClassification.TASK, @@ -231,7 +272,7 @@ public MessageIntent decide(String sourceText, ZoneId zoneId) { ); } - String noteTitle = cleanupTitle(text, "Заметка"); + String noteTitle = cleanupTitle(stripTaskCommandPhrases(stripCreateCommandPhrases(text)), "Заметка"); return new MessageIntent( BotAction.CREATE_NOTE, FilterClassification.INFO_ONLY, @@ -243,7 +284,7 @@ public MessageIntent decide(String sourceText, ZoneId zoneId) { null, null, null, - text, + null, link, "📝 Сохранил как заметку." ); @@ -526,13 +567,43 @@ private String cleanupMeetingTitle(String text) { return cleanupTitle(title, "Встреча"); } + private String cleanupTaskTitle(String text) { + String title = text == null ? "" : text; + title = stripTaskCommandPhrases(title); + title = stripCreateCommandPhrases(title); + title = title.replaceAll("(?iu)^\\s*(задач\\p{L}*|task)\\s*[:\\-]?\\s*", ""); + title = title.replaceAll("(?iu)\\b(сегодня|завтра|послезавтра|today|tomorrow)\\b", " "); + title = title.replaceAll("(?iu)\\b(до|к)\\s+\\d{1,2}[:.]\\d{2}\\b", " "); + title = title.replaceAll("(?iu)\\bв\\s+\\d{1,2}[:.]\\d{2}\\b", " "); + title = title.replaceAll("(?iu)\\b\\d{1,2}[./]\\d{1,2}(?:[./]\\d{2,4})?\\b", " "); + title = title.replaceAll("\\s+", " ").trim(); + return cleanupTitle(title, "Задача"); + } + private String stripCreateCommandPhrases(String source) { if (source == null) { return ""; } String cleaned = source; - cleaned = cleaned.replaceAll("(?iu)\\b(созда(й|ть)|добав(ь|ить)|запланиру(й|йте|ю)|сдела(й|ть))\\s+(мне\\s+)?(событи\\p{L}*|встреч\\p{L}*)\\b", " "); - cleaned = cleaned.replaceAll("(?iu)\\b(созда(й|ть)|добав(ь|ить)|запланиру(й|йте|ю)|сдела(й|ть))\\b", " "); + cleaned = cleaned.replaceAll("(?iu)\\b(созда\\p{L}*|добав\\p{L}*|запланиру\\p{L}*|сдела\\p{L}*)\\s+(мне\\s+)?(событи\\p{L}*|встреч\\p{L}*)\\b", " "); + cleaned = cleaned.replaceAll("(?iu)\\b(созда\\p{L}*|добав\\p{L}*|запланиру\\p{L}*|сдела\\p{L}*)\\b", " "); + cleaned = cleaned.replaceAll("\\s+", " ").trim(); + return cleaned; + } + + private String stripTaskCommandPhrases(String source) { + if (source == null) { + return ""; + } + String cleaned = source; + cleaned = cleaned.replaceAll( + "(?iu)^\\s*(?:ну\\s+)?(?:пожалуйста\\s+)?(?:созда\\p{L}*|добав\\p{L}*|сдела\\p{L}*)\\s+(?:мне\\s+)?(?:задач\\p{L}*|task)\\s*", + " " + ); + cleaned = cleaned.replaceAll( + "(?iu)\\b(?:созда\\p{L}*|добав\\p{L}*|сдела\\p{L}*)\\s+(?:мне\\s+)?(?:задач\\p{L}*|task)\\b", + " " + ); cleaned = cleaned.replaceAll("\\s+", " ").trim(); return cleaned; } @@ -579,7 +650,10 @@ private OffsetDateTime inferMeetingStart(String normalized, ZoneId zoneId) { private OffsetDateTime inferTaskDue(String normalized, ZoneId zoneId) { LocalDate date = inferDate(normalized, zoneId); - LocalTime time = hasAny(normalized, "сегодня", "today") ? LocalTime.of(20, 0) : LocalTime.of(12, 0); + LocalTime explicit = extractExplicitTime(normalized); + LocalTime time = explicit != null + ? explicit + : OffsetDateTime.now(zoneId).toLocalTime().withSecond(0).withNano(0); return OffsetDateTime.now(zoneId) .withYear(date.getYear()) .withMonth(date.getMonthValue()) @@ -590,6 +664,35 @@ private OffsetDateTime inferTaskDue(String normalized, ZoneId zoneId) { .withNano(0); } + private LocalTime extractExplicitTime(String normalized) { + String withoutDates = normalized.replaceAll("\\b\\d{1,2}[.]\\d{1,2}(?:[.]\\d{2,4})?\\b", " "); + Matcher timeMatcher = TIME_COLON_PATTERN.matcher(withoutDates); + while (timeMatcher.find()) { + int hour = Integer.parseInt(timeMatcher.group(1)); + int minute = Integer.parseInt(timeMatcher.group(2)); + if (hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59) { + return LocalTime.of(hour, minute); + } + } + + Matcher hourMatcher = TIME_HOURS_PATTERN.matcher(withoutDates); + while (hourMatcher.find()) { + int hour = Integer.parseInt(hourMatcher.group(1)); + if (hour >= 0 && hour <= 23) { + return LocalTime.of(hour, 0); + } + } + + Matcher hourWordsMatcher = HOUR_WORDS_PATTERN.matcher(withoutDates); + while (hourWordsMatcher.find()) { + Integer hour = parseRussianWordsNumber(hourWordsMatcher.group(1)); + if (hour != null && hour >= 0 && hour <= 23) { + return LocalTime.of(hour, 0); + } + } + return null; + } + private LocalDate inferDate(String normalized, ZoneId zoneId) { LocalDate now = LocalDate.now(zoneId); if (hasAny(normalized, "сегодня", "today")) { diff --git a/backend-core/src/main/java/com/aichef/service/NotificationDispatchService.java b/backend-core/src/main/java/com/aichef/service/NotificationDispatchService.java index a72fded..6e37163 100644 --- a/backend-core/src/main/java/com/aichef/service/NotificationDispatchService.java +++ b/backend-core/src/main/java/com/aichef/service/NotificationDispatchService.java @@ -14,6 +14,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Duration; import java.time.OffsetDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; @@ -65,12 +66,17 @@ public void dispatchDueNotifications() { private String buildMessage(Notification notification) { if (notification.getRelatedType() == RelatedType.MEETING) { Meeting meeting = meetingRepository.findById(notification.getRelatedId()).orElse(null); - if (meeting == null) { + if (meeting == null || meeting.getStatus() == com.aichef.domain.enums.MeetingStatus.CANCELED) { return null; } ZoneId zoneId = resolveZone(notification.getUser()); String time = meeting.getStartsAt().atZoneSameInstant(zoneId).format(REMINDER_TIME_FMT); - return "⏰ Напоминание: через 30 минут событие \"" + meeting.getTitle() + "\".\n🕒 " + time; + long minutesBefore = Duration.between(notification.getNotifyAt(), meeting.getStartsAt()).toMinutes(); + if (minutesBefore <= 1) { + return "⏰ Напоминание: событие \"" + meeting.getTitle() + "\" начинается сейчас.\n🕒 " + time; + } + return "⏰ Напоминание: событие \"" + meeting.getTitle() + "\" начнется через " + + formatLeadTime(minutesBefore) + ".\n🕒 " + time; } if (notification.getRelatedType() == RelatedType.TASK) { @@ -84,6 +90,18 @@ private String buildMessage(Notification notification) { return null; } + private String formatLeadTime(long minutesBefore) { + if (minutesBefore < 60) { + return minutesBefore + " мин"; + } + long hours = minutesBefore / 60; + long mins = minutesBefore % 60; + if (mins == 0) { + return hours + " ч"; + } + return hours + " ч " + mins + " мин"; + } + private ZoneId resolveZone(User user) { if (user == null || user.getTimezone() == null || user.getTimezone().isBlank()) { return ZoneId.of("Europe/Moscow"); diff --git a/backend-core/src/main/java/com/aichef/service/OllamaStructuredParsingService.java b/backend-core/src/main/java/com/aichef/service/OllamaStructuredParsingService.java index 3931b33..490b230 100644 --- a/backend-core/src/main/java/com/aichef/service/OllamaStructuredParsingService.java +++ b/backend-core/src/main/java/com/aichef/service/OllamaStructuredParsingService.java @@ -65,7 +65,7 @@ private String buildPrompt(String today, String text) { today=%s Схема: { - "intent":"create_meeting|create_task|other", + "intent":"create_meeting|create_task|create_note|other", "title":"string|null", "date":"YYYY-MM-DD|null", "time":"HH:mm|null", @@ -239,5 +239,13 @@ public boolean hasAnyData() { public boolean isCreateMeetingIntent() { return intent != null && intent.equalsIgnoreCase("create_meeting"); } + + public boolean isCreateTaskIntent() { + return intent != null && intent.equalsIgnoreCase("create_task"); + } + + public boolean isCreateNoteIntent() { + return intent != null && intent.equalsIgnoreCase("create_note"); + } } } diff --git a/backend-core/src/main/java/com/aichef/service/TelegramBotService.java b/backend-core/src/main/java/com/aichef/service/TelegramBotService.java index 1b7c174..8970c85 100644 --- a/backend-core/src/main/java/com/aichef/service/TelegramBotService.java +++ b/backend-core/src/main/java/com/aichef/service/TelegramBotService.java @@ -626,10 +626,24 @@ private String applyIntent(User user, InboundItem inboundItem, MessageIntent int if (intent.action() == BotAction.CREATE_NOTE) { Note note = new Note(); note.setUser(user); - note.setTitle(TextNormalization.normalizeRussian(intent.title() == null ? "Заметка" : intent.title())); - note.setContent(TextNormalization.normalizeRussian(intent.noteContent() == null ? "" : intent.noteContent())); + String rawTitle = intent.title() == null ? "Заметка" : intent.title(); + String normalizedTitle = TextNormalization.normalizeRussian(rawTitle); + String rawContent = intent.noteContent() == null ? "" : intent.noteContent(); + String normalizedContent = TextNormalization.normalizeRussian(rawContent); + + note.setTitle(normalizedTitle); + if (normalizedContent != null + && !normalizedContent.isBlank() + && !normalizedContent.trim().equalsIgnoreCase((normalizedTitle == null ? "" : normalizedTitle).trim())) { + note.setContent(normalizedContent); + } else { + note.setContent(""); + } noteRepository.save(note); - return "📝 Заметка сохранена.\nID: " + note.getId(); + log.info("Note created from bot. userId={}, telegramId={}, noteId={}, title={}", + user.getId(), user.getTelegramId(), note.getId(), note.getTitle()); + String preview = note.getTitle() == null || note.getTitle().isBlank() ? "Заметка" : note.getTitle(); + return "📝 Заметка сохранена\nНазвание: " + preview; } if (intent.action() == BotAction.EDIT_NOTE) { @@ -735,6 +749,8 @@ private Meeting createMeetingWithReminder( meeting.setStartsAt(startsAt); meeting.setEndsAt(endsAt); meeting.setExternalLink(externalLink); + meeting.setColor("#93c5fd"); + meeting.setReminderMinutesBefore(30); meeting.setStatus(MeetingStatus.CONFIRMED); GoogleCalendarService.CreatedGoogleEvent googleEvent = googleCalendarService.createEvent( @@ -767,8 +783,20 @@ private void scheduleMeetingReminder(User user, Meeting meeting) { if (user == null || meeting == null || meeting.getStartsAt() == null || meeting.getId() == null) { return; } - OffsetDateTime notifyAt = meeting.getStartsAt().minusMinutes(30); - if (notifyAt.isBefore(OffsetDateTime.now())) { + OffsetDateTime now = OffsetDateTime.now(); + OffsetDateTime startNotifyAt = meeting.getStartsAt().isAfter(now) + ? meeting.getStartsAt() + : now.plusSeconds(5); + createMeetingNotification(user, meeting, startNotifyAt); + + OffsetDateTime beforeNotifyAt = meeting.getStartsAt().minusMinutes(30); + if (beforeNotifyAt.isAfter(now) && !beforeNotifyAt.isEqual(startNotifyAt)) { + createMeetingNotification(user, meeting, beforeNotifyAt); + } + } + + private void createMeetingNotification(User user, Meeting meeting, OffsetDateTime notifyAt) { + if (notifyAt == null) { return; } Notification notification = new Notification(); diff --git a/backend-core/src/main/java/com/aichef/service/TelegramPollingService.java b/backend-core/src/main/java/com/aichef/service/TelegramPollingService.java index f49579a..3e67735 100644 --- a/backend-core/src/main/java/com/aichef/service/TelegramPollingService.java +++ b/backend-core/src/main/java/com/aichef/service/TelegramPollingService.java @@ -13,7 +13,6 @@ import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; -import java.net.URI; import java.util.List; import java.util.Map; import java.util.concurrent.Executor; @@ -48,10 +47,13 @@ public TelegramPollingService(TelegramProperties properties, private final AtomicBoolean pollingConflictLogged = new AtomicBoolean(false); private final AtomicInteger networkErrorStreak = new AtomicInteger(0); private final AtomicLong nextPollAllowedAtMs = new AtomicLong(0); + private final AtomicLong webhookStateCheckedAtMs = new AtomicLong(0); + private final AtomicBoolean webhookActive = new AtomicBoolean(false); + private static final long WEBHOOK_STATE_CACHE_MS = 30_000; @Scheduled(fixedDelayString = "${app.telegram.poll-interval-ms:3000}") public void pollUpdates() { - if (isWebhookModeEnabled(properties.publicBaseUrl())) { + if (properties.useWebhook() && isWebhookCurrentlyActive()) { return; } if (System.currentTimeMillis() < nextPollAllowedAtMs.get()) { @@ -93,6 +95,12 @@ public void pollUpdates() { } } catch (HttpClientErrorException.Conflict e) { String msg = e.getMessage() == null ? "" : e.getMessage(); + if (msg.contains("can't use getUpdates method while webhook is active")) { + log.warn("Polling conflict with active webhook detected. Deleting webhook and continuing in polling mode."); + telegramBotService.deleteWebhook(false); + resetWebhookStateCache(); + return; + } if (msg.contains("terminated by other getUpdates request")) { if (pollingConflictLogged.compareAndSet(false, true)) { log.error("Polling conflict: another bot instance is running. Stop extra instance to continue polling."); @@ -151,19 +159,33 @@ private String summarizeCause(Throwable cause) { return cause.getClass().getSimpleName() + ": " + message; } - private boolean isWebhookModeEnabled(String baseUrl) { - if (baseUrl == null || baseUrl.isBlank()) { - return false; + private boolean isWebhookCurrentlyActive() { + long now = System.currentTimeMillis(); + if (now - webhookStateCheckedAtMs.get() < WEBHOOK_STATE_CACHE_MS) { + return webhookActive.get(); } + + boolean active = false; try { - URI uri = URI.create(baseUrl.trim()); - String host = uri.getHost(); - if (host == null) { - return false; + Map response = telegramRestClient.get() + .uri("/bot{token}/getWebhookInfo", properties.botToken()) + .retrieve() + .body(Map.class); + if (response != null && Boolean.TRUE.equals(response.get("ok")) && response.get("result") instanceof Map result) { + Object urlObj = result.get("url"); + String url = urlObj instanceof String s ? s.trim() : ""; + active = !url.isBlank(); } - return !("localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host) || "::1".equals(host)); } catch (Exception e) { - return false; + log.warn("Failed to check webhook state, assuming polling mode. error={}", e.getMessage()); } + webhookActive.set(active); + webhookStateCheckedAtMs.set(now); + return active; + } + + private void resetWebhookStateCache() { + webhookActive.set(false); + webhookStateCheckedAtMs.set(0); } } diff --git a/db/schema.sql b/db/schema.sql index 229d315..676b44f 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -95,6 +95,7 @@ CREATE TABLE meetings ( ends_at TIMESTAMPTZ NOT NULL, location TEXT NULL, external_link TEXT NULL, + color TEXT NULL, google_event_id TEXT NULL, status TEXT NOT NULL DEFAULT 'confirmed' CHECK (status IN ('tentative', 'confirmed', 'canceled')), created_at TIMESTAMP NOT NULL DEFAULT now(), diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..eb66121 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,69 @@ +services: + postgres: + image: postgres:16 + container_name: aichef-postgres + restart: unless-stopped + environment: + POSTGRES_DB: aichef + POSTGRES_USER: aichef + POSTGRES_PASSWORD: aichef + ports: + - "5432:5432" + volumes: + - aichef_pg_data:/var/lib/postgresql/data + + miniapp: + container_name: aichef-miniapp + build: + context: . + dockerfile: miniapp-backend/Dockerfile.render + restart: unless-stopped + env_file: + - .env + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/aichef + SPRING_DATASOURCE_USERNAME: aichef + SPRING_DATASOURCE_PASSWORD: aichef + MINIAPP_SERVER_PORT: 8010 + MINIAPP_PUBLIC_URL: ${MINIAPP_PUBLIC_URL:-http://185.192.246.155:5174/} + APP_PUBLIC_BASE_URL: ${APP_PUBLIC_BASE_URL:-http://185.192.246.155:8011} + depends_on: + - postgres + ports: + - "8010:8010" + + telegram: + container_name: aichef-telegram + build: + context: . + dockerfile: telegram-backend/Dockerfile.render + restart: unless-stopped + env_file: + - .env + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/aichef + SPRING_DATASOURCE_USERNAME: aichef + SPRING_DATASOURCE_PASSWORD: aichef + TELEGRAM_SERVER_PORT: 8011 + APP_PUBLIC_BASE_URL: ${APP_PUBLIC_BASE_URL:-http://185.192.246.155:8011} + MINIAPP_PUBLIC_URL: ${MINIAPP_PUBLIC_URL:-http://185.192.246.155:5174/} + APP_VOSK_PYTHON: /opt/venv/bin/python + APP_VOSK_MODEL_PATH: /opt/models/vosk-model-small-ru-0.22 + depends_on: + - postgres + ports: + - "8011:8011" + + frontend: + container_name: aichef-frontend + build: + context: . + dockerfile: frontend/Dockerfile.render + restart: unless-stopped + environment: + FRONTEND_PORT: 5174 + ports: + - "5174:5174" + +volumes: + aichef_pg_data: diff --git a/frontend/src/main/resources/static/app.js b/frontend/src/main/resources/static/app.js index 88d031b..40f1865 100644 --- a/frontend/src/main/resources/static/app.js +++ b/frontend/src/main/resources/static/app.js @@ -2,7 +2,8 @@ const menuItems = [ { icon: "▦", label: "Расписание", href: "index.html", key: "schedule" }, { icon: "✓", label: "Задачи", href: "tasks.html", key: "tasks" }, - { icon: "✎", label: "Заметки", href: "notes.html", key: "notes" } + { icon: "✎", label: "Заметки", href: "notes.html", key: "notes" }, + { icon: "◉", label: "Профиль", href: "profile.html", key: "profile" } ]; const dayNames = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]; @@ -10,6 +11,9 @@ const API_REQUEST_TIMEOUT_MS = 7000; const PROFILE_REQUEST_TIMEOUT_MS = 1500; const TELEGRAM_ID_STORAGE_KEY = "aical_telegram_id"; + const CALENDAR_VISIBILITY_KEY = "impera_calendar_visibility"; + const WAKEUP_TIMEOUT_MS = 45000; + const WAKEUP_STEP_MS = 2500; const page = document.body.dataset.page || "schedule"; @@ -28,6 +32,10 @@ scheduleStatus: document.getElementById("scheduleStatus"), eventModal: document.getElementById("eventModal"), eventCloseBtn: document.getElementById("eventCloseBtn"), + eventMoreBtn: document.getElementById("eventMoreBtn"), + eventMoreMenu: document.getElementById("eventMoreMenu"), + eventDuplicateBtn: document.getElementById("eventDuplicateBtn"), + eventDeleteBtn: document.getElementById("eventDeleteBtn"), eventEditBtn: document.getElementById("eventEditBtn"), eventTitle: document.getElementById("eventTitle"), eventDateTime: document.getElementById("eventDateTime"), @@ -36,7 +44,10 @@ eventEditTitle: document.getElementById("eventEditTitle"), eventEditDate: document.getElementById("eventEditDate"), eventEditTime: document.getElementById("eventEditTime"), - eventEditDuration: document.getElementById("eventEditDuration") + eventEditDuration: document.getElementById("eventEditDuration"), + eventEditReminder: document.getElementById("eventEditReminder"), + eventEditColor: document.getElementById("eventEditColor"), + eventSubmitBtn: document.getElementById("eventSubmitBtn") }; const state = { @@ -44,7 +55,9 @@ meetings: [], nowTimer: null, activeMeetingId: "", - editMode: false + editMode: false, + formMode: "edit", + wakeUpDone: false }; init(); @@ -105,10 +118,28 @@ } }); } + if (el.eventMoreBtn) { + el.eventMoreBtn.addEventListener("click", () => { + if (!el.eventMoreMenu) return; + el.eventMoreMenu.classList.toggle("hidden"); + }); + } + if (el.eventDuplicateBtn) { + el.eventDuplicateBtn.addEventListener("click", onDuplicateMeeting); + } + if (el.eventDeleteBtn) { + el.eventDeleteBtn.addEventListener("click", onDeleteMeeting); + } if (el.eventEditBtn) { el.eventEditBtn.addEventListener("click", () => { const nextMode = !state.editMode; setEditMode(nextMode); + if (nextMode && el.eventEditTitle) { + window.setTimeout(() => { + el.eventEditTitle.focus(); + el.eventEditTitle.select(); + }, 0); + } }); } if (el.eventEditForm) { @@ -119,6 +150,13 @@ closeEventModal(); } }); + document.addEventListener("click", (e) => { + if (!el.eventMoreMenu || !el.eventMoreBtn) return; + if (el.eventMoreMenu.classList.contains("hidden")) return; + const target = e.target; + if (target instanceof Node && (el.eventMoreMenu.contains(target) || el.eventMoreBtn.contains(target))) return; + el.eventMoreMenu.classList.add("hidden"); + }); } function renderMenu() { @@ -147,7 +185,8 @@ const tgUser = tg && tg.initDataUnsafe ? tg.initDataUnsafe.user : null; const userId = tgUser && tgUser.id ? String(tgUser.id) : getTelegramIdFromContext(); const photoUrl = tgUser && tgUser.photo_url ? String(tgUser.photo_url) : ""; - const username = tgUser && tgUser.username ? `@${tgUser.username}` : ""; + const usernameRaw = tgUser && tgUser.username ? String(tgUser.username) : ""; + const username = usernameRaw ? `@${usernameRaw}` : ""; const displayName = username || (userId ? `id${userId}` : "user"); if (el.userName) { @@ -175,10 +214,27 @@ img.style.objectFit = "cover"; el.userAvatar.textContent = ""; el.userAvatar.appendChild(img); + attachProfileNavigation(usernameRaw); return; } el.userAvatar.textContent = userId ? String(userId).slice(-2) : "ID"; + attachProfileNavigation(usernameRaw); + } + + function attachProfileNavigation(usernameRaw) { + const card = document.querySelector(".user-card"); + if (!card) return; + const profileUrl = `profile.html${window.location.search || ""}`; + card.classList.add("profile-link"); + card.addEventListener("click", () => { + const tg = window.Telegram && window.Telegram.WebApp ? window.Telegram.WebApp : null; + if (tg && typeof tg.openTelegramLink === "function" && usernameRaw) { + tg.openTelegramLink(`https://t.me/${usernameRaw}`); + return; + } + window.location.href = profileUrl; + }); } async function resolveTitlePrefix() { @@ -232,7 +288,11 @@ } async function loadMeetingsAndRender() { - setScheduleStatus("Загрузка..."); + if (!state.wakeUpDone) { + await warmUpBackends(); + state.wakeUpDone = true; + } + setScheduleStatus("Подготавливаю события и синхронизирую календарь..."); const from = toYmd(state.weekStart); const to = toYmd(addDays(state.weekStart, 6)); const endpoints = getMeetingEndpoints().map((p) => `${p}?from=${from}&to=${to}`); @@ -244,12 +304,36 @@ return; } - state.meetings = Array.isArray(res.data) ? res.data : []; + const rawMeetings = Array.isArray(res.data) ? res.data : []; + state.meetings = filterMeetingsByVisibility(rawMeetings); renderWeekSkeleton(); renderMeetings(); setScheduleStatus(state.meetings.length ? "" : "Событий на неделю нет"); } + function filterMeetingsByVisibility(meetings) { + const visibility = readCalendarVisibility(); + const internalOn = visibility.internal !== false; + const googleOn = visibility.google !== false; + const icalOn = visibility.ical !== false; + return meetings.filter((m) => { + const isGoogleLinked = !!(m && m.googleEventId); + if (isGoogleLinked && !googleOn) return false; + if (!isGoogleLinked && (!internalOn || !icalOn)) return false; + return true; + }); + } + + function readCalendarVisibility() { + try { + const parsed = JSON.parse(localStorage.getItem(CALENDAR_VISIBILITY_KEY) || "{}"); + if (!parsed || typeof parsed !== "object") return {}; + return parsed; + } catch { + return {}; + } + } + function renderWeekSkeleton() { renderRange(); renderHead(); @@ -348,6 +432,7 @@ const pill = document.createElement("button"); pill.type = "button"; pill.className = "meeting-pill"; + applyMeetingColor(pill, meeting.color); pill.style.top = `${top}px`; pill.style.left = `${left}px`; pill.style.width = `${width}px`; @@ -366,6 +451,7 @@ if (!meeting || !el.eventModal) return; state.activeMeetingId = String(meeting.id || ""); state.editMode = false; + state.formMode = "edit"; fillEventModal(meeting); setEventModalStatus(""); setEditMode(false); @@ -376,6 +462,8 @@ if (!el.eventModal) return; el.eventModal.classList.add("hidden"); state.activeMeetingId = ""; + state.formMode = "edit"; + if (el.eventMoreMenu) el.eventMoreMenu.classList.add("hidden"); setEventModalStatus(""); } @@ -402,10 +490,20 @@ const mins = isValidDate(startsAt) && isValidDate(endsAt) ? Math.max(5, Math.round((endsAt - startsAt) / 60000)) : 60; el.eventEditDuration.value = String(mins); } + if (el.eventEditReminder) { + const reminder = Number.isFinite(Number(meeting.reminderMinutesBefore)) + ? Number(meeting.reminderMinutesBefore) + : 30; + el.eventEditReminder.value = String(Math.max(0, reminder)); + } + if (el.eventEditColor) { + el.eventEditColor.value = normalizeHexColor(meeting.color) || "#93c5fd"; + } } function setEditMode(enabled) { state.editMode = Boolean(enabled); + updateSubmitButtonText(); if (el.eventEditForm) { el.eventEditForm.classList.toggle("hidden", !state.editMode); } @@ -426,10 +524,20 @@ const date = String(el.eventEditDate && el.eventEditDate.value ? el.eventEditDate.value : "").trim(); const time = String(el.eventEditTime && el.eventEditTime.value ? el.eventEditTime.value : "").trim(); const duration = Number(el.eventEditDuration && el.eventEditDuration.value ? el.eventEditDuration.value : 0); + const reminderMinutesBefore = Number(el.eventEditReminder && el.eventEditReminder.value ? el.eventEditReminder.value : 30); + const color = normalizeHexColor(el.eventEditColor && el.eventEditColor.value ? el.eventEditColor.value : ""); if (!title || !date || !time || !Number.isFinite(duration) || duration <= 0) { setEventModalStatus("Проверьте название, дату, время и длительность"); return; } + if (!Number.isFinite(reminderMinutesBefore) || reminderMinutesBefore < 0) { + setEventModalStatus("Некорректное значение напоминания"); + return; + } + if (!color) { + setEventModalStatus("Некорректный цвет"); + return; + } const startsAt = parseLocalDateTime(date, time); if (!startsAt) { @@ -440,34 +548,106 @@ const payload = { title, startsAt: toOffsetIso(startsAt), - endsAt: toOffsetIso(endsAt) + endsAt: toOffsetIso(endsAt), + color, + reminderMinutesBefore }; setEventModalStatus("Сохраняю..."); - const res = await requestWithFallback( - getMeetingEndpoints().map((base) => `${base}/${activeMeeting.id}`), - [{ method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }] - ); + const saveTargets = state.formMode === "duplicate" + ? getMeetingEndpoints() + : getMeetingEndpoints().map((base) => `${base}/${activeMeeting.id}`); + const saveVariants = state.formMode === "duplicate" + ? [{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }] + : [ + { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }, + { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) } + ]; + + const res = await requestWithFallback(saveTargets, saveVariants); if (!res.success) { - setEventModalStatus(res.message || "Ошибка сохранения"); + const detail = res.status ? ` (HTTP ${res.status})` : ""; + setEventModalStatus((res.message || "Ошибка сохранения, проверь доступ и формат даты/времени") + detail); return; } - setEventModalStatus("Сохранено"); + const wasDuplicate = state.formMode === "duplicate"; + if (wasDuplicate) { + setEventModalStatus("Копия создана"); + state.formMode = "edit"; + } else { + setEventModalStatus("Сохранено"); + } setEditMode(false); await loadMeetingsAndRender(); - const updated = state.meetings.find((m) => String(m.id) === String(activeMeeting.id)); + const focusId = wasDuplicate + ? String(res.data && res.data.id ? res.data.id : activeMeeting.id) + : String(activeMeeting.id); + const updated = state.meetings.find((m) => String(m.id) === focusId); if (updated) { + state.activeMeetingId = String(updated.id || ""); fillEventModal(updated); } } + async function onDuplicateMeeting() { + const activeMeeting = state.meetings.find((m) => String(m.id) === String(state.activeMeetingId)); + if (!activeMeeting) { + setEventModalStatus("Событие не найдено"); + return; + } + if (el.eventMoreMenu) el.eventMoreMenu.classList.add("hidden"); + + state.formMode = "duplicate"; + setEditMode(true); + if (el.eventEditTitle) { + const originalTitle = String(activeMeeting.title || "Событие").trim(); + el.eventEditTitle.value = `Копия: ${originalTitle}`; + window.setTimeout(() => { + el.eventEditTitle.focus(); + el.eventEditTitle.select(); + }, 0); + } + setEventModalStatus("Режим дублирования: укажите новое название, дату и время, затем сохраните."); + } + + async function onDeleteMeeting() { + const activeMeeting = state.meetings.find((m) => String(m.id) === String(state.activeMeetingId)); + if (!activeMeeting) { + setEventModalStatus("Событие не найдено"); + return; + } + if (el.eventMoreMenu) el.eventMoreMenu.classList.add("hidden"); + const ok = window.confirm("Удалить это мероприятие?"); + if (!ok) return; + + setEventModalStatus("Удаляю..."); + const res = await requestWithFallback( + getMeetingEndpoints().map((base) => `${base}/${activeMeeting.id}`), + [{ method: "DELETE" }] + ); + if (!res.success) { + const detail = res.status ? ` (HTTP ${res.status})` : ""; + setEventModalStatus((res.message || "Ошибка удаления") + detail); + return; + } + + closeEventModal(); + await loadMeetingsAndRender(); + setScheduleStatus("Событие удалено"); + } + function setEventModalStatus(message) { if (el.eventModalStatus) { el.eventModalStatus.textContent = message || ""; } } + function updateSubmitButtonText() { + if (!el.eventSubmitBtn) return; + el.eventSubmitBtn.textContent = state.formMode === "duplicate" ? "Создать копию" : "Сохранить"; + } + function formatMeetingDateTimeLine(startsAt, endsAt) { if (!isValidDate(startsAt) || !isValidDate(endsAt)) { return ""; @@ -524,7 +704,10 @@ API_REQUEST_TIMEOUT_MS ); if (!response.ok) { - return { success: false, status: response.status, message: apiErrorMessage(response.status) }; + const text = await response.text().catch(() => ""); + const baseMessage = apiErrorMessage(response.status); + const details = text && text.length < 240 ? `: ${text}` : ""; + return { success: false, status: response.status, message: `${baseMessage}${details}` }; } if (response.status === 204) { return { success: true, status: response.status, data: null }; @@ -570,7 +753,7 @@ function apiErrorMessage(status) { if (status === 401) return "Нет доступа. Открой Mini App через Telegram"; - if (status === 404) return "API не найден. Проверь apiBaseUrl"; + if (status === 404) return "Календарный сервис поднимается. Данные скоро появятся."; return `Ошибка API (${status})`; } @@ -595,10 +778,6 @@ const list = [base]; if (!explicitBase) { - const inferredRenderBase = inferRenderMiniAppApiBase(); - if (inferredRenderBase) { - list.push(inferredRenderBase); - } if (host) { list.push(`${protocol}//${host}:8011`); list.push(`${protocol}//${host}:8010`); @@ -666,30 +845,67 @@ } } - function inferRenderMiniAppApiBase() { - const protocol = window.location.protocol || "https:"; - const hostname = window.location.hostname || ""; - if (!hostname || !hostname.endsWith(".onrender.com")) { - return ""; + function saveTelegramId(value) { + try { + if (!value) return; + localStorage.setItem(TELEGRAM_ID_STORAGE_KEY, String(value)); + } catch { + // ignore } - if (hostname.includes("-frontend.")) { - return `${protocol}//${hostname.replace("-frontend.", "-miniapp-api.")}`; + } + + async function warmUpBackends() { + const startedAt = Date.now(); + const bases = getApiBaseCandidates(); + const extras = getSiblingServiceOrigins(); + for (const origin of extras) { + fireWakePing(origin); } - if (hostname.includes("frontend")) { - return `${protocol}//${hostname.replace("frontend", "miniapp-api")}`; + while (Date.now() - startedAt < WAKEUP_TIMEOUT_MS) { + for (const base of bases) { + const ok = await probeApiBase(base); + if (ok) { + setScheduleStatus("Данные готовы, отображаю события..."); + return; + } + } + const sec = Math.max(1, Math.round((Date.now() - startedAt) / 1000)); + setScheduleStatus(`Подключаю сервисы и собираю ваши данные (${sec}s)...`); + await sleep(WAKEUP_STEP_MS); } - return ""; } - function saveTelegramId(value) { + async function probeApiBase(base) { + const auth = buildAuth(); + const url = new URL(base + "/api/miniapp/me"); + if (auth.telegramId) { + url.searchParams.set("telegramId", auth.telegramId); + } try { - if (!value) return; - localStorage.setItem(TELEGRAM_ID_STORAGE_KEY, String(value)); + const response = await fetchWithTimeout(url.toString(), { headers: auth.headers }, 3500); + return response.ok || response.status === 401 || response.status === 403 || response.status === 400; + } catch { + return false; + } + } + + function getSiblingServiceOrigins() { + return []; + } + + function fireWakePing(origin) { + if (!origin) return; + try { + fetch(origin.replace(/\/+$/, "") + "/actuator/health", { mode: "no-cors", cache: "no-store" }).catch(() => {}); } catch { // ignore } } + function sleep(ms) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); + } + function setScheduleStatus(message) { if (el.scheduleStatus) el.scheduleStatus.textContent = message || ""; } @@ -782,6 +998,30 @@ return s[0].toUpperCase() + s.slice(1); } + function normalizeHexColor(value) { + const v = String(value || "").trim(); + return /^#([0-9a-fA-F]{6})$/.test(v) ? v.toLowerCase() : ""; + } + + function applyMeetingColor(element, color) { + if (!element) return; + const safe = normalizeHexColor(color) || "#93c5fd"; + element.style.borderColor = safe; + element.style.backgroundColor = withAlpha(safe, 0.3); + element.style.color = "#1e3a8a"; + } + + function withAlpha(hex, alpha) { + const safe = normalizeHexColor(hex); + if (!safe) return "rgba(147,197,253,0.3)"; + const raw = safe.slice(1); + const r = parseInt(raw.slice(0, 2), 16); + const g = parseInt(raw.slice(2, 4), 16); + const b = parseInt(raw.slice(4, 6), 16); + const a = Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 0.3; + return `rgba(${r}, ${g}, ${b}, ${a})`; + } + function isValidDate(value) { return value instanceof Date && !Number.isNaN(value.getTime()); } diff --git a/frontend/src/main/resources/static/common.js b/frontend/src/main/resources/static/common.js index fa329bb..485f38b 100644 --- a/frontend/src/main/resources/static/common.js +++ b/frontend/src/main/resources/static/common.js @@ -2,12 +2,15 @@ const menuItems = [ { icon: "▦", label: "Расписание", href: "index.html", key: "schedule" }, { icon: "✓", label: "Задачи", href: "tasks.html", key: "tasks" }, - { icon: "✎", label: "Заметки", href: "notes.html", key: "notes" } + { icon: "✎", label: "Заметки", href: "notes.html", key: "notes" }, + { icon: "◉", label: "Профиль", href: "profile.html", key: "profile" } ]; const page = document.body.dataset.page || "schedule"; const PROFILE_REQUEST_TIMEOUT_MS = 1500; const TELEGRAM_ID_STORAGE_KEY = "aical_telegram_id"; + const WAKEUP_TIMEOUT_MS = 45000; + const WAKEUP_STEP_MS = 2500; const el = { menu: document.getElementById("menu"), @@ -65,7 +68,8 @@ const tgUser = tg && tg.initDataUnsafe ? tg.initDataUnsafe.user : null; const userId = tgUser && tgUser.id ? String(tgUser.id) : getTelegramIdFromContext(); const photoUrl = tgUser && tgUser.photo_url ? String(tgUser.photo_url) : ""; - const username = tgUser && tgUser.username ? `@${tgUser.username}` : ""; + const usernameRaw = tgUser && tgUser.username ? String(tgUser.username) : ""; + const username = usernameRaw ? `@${usernameRaw}` : ""; const displayName = username || (userId ? `id${userId}` : "user"); if (el.userName) { @@ -93,10 +97,27 @@ img.style.objectFit = "cover"; el.userAvatar.textContent = ""; el.userAvatar.appendChild(img); + attachProfileNavigation(usernameRaw); return; } el.userAvatar.textContent = userId ? String(userId).slice(-2) : "ID"; + attachProfileNavigation(usernameRaw); + } + + function attachProfileNavigation(usernameRaw) { + const card = document.querySelector(".user-card"); + if (!card) return; + const profileUrl = `profile.html${window.location.search || ""}`; + card.classList.add("profile-link"); + card.addEventListener("click", () => { + const tg = window.Telegram && window.Telegram.WebApp ? window.Telegram.WebApp : null; + if (tg && typeof tg.openTelegramLink === "function" && usernameRaw) { + tg.openTelegramLink(`https://t.me/${usernameRaw}`); + return; + } + window.location.href = profileUrl; + }); } function getTelegramIdFromContext() { @@ -155,7 +176,8 @@ getApiBaseUrl, getApiBaseCandidates, buildAuth, - getEndpointCandidates + getEndpointCandidates, + wakeUpServices }; function getApiBaseUrl() { @@ -203,10 +225,6 @@ const list = [base]; if (!explicitBase) { - const inferredRenderBase = inferRenderMiniAppApiBase(); - if (inferredRenderBase) { - list.push(inferredRenderBase); - } if (host) { list.push(`${protocol}//${host}:8011`); list.push(`${protocol}//${host}:8010`); @@ -252,21 +270,6 @@ } } - function inferRenderMiniAppApiBase() { - const protocol = window.location.protocol || "https:"; - const hostname = window.location.hostname || ""; - if (!hostname || !hostname.endsWith(".onrender.com")) { - return ""; - } - if (hostname.includes("-frontend.")) { - return `${protocol}//${hostname.replace("-frontend.", "-miniapp-api.")}`; - } - if (hostname.includes("frontend")) { - return `${protocol}//${hostname.replace("frontend", "miniapp-api")}`; - } - return ""; - } - function saveTelegramId(value) { try { if (!value) return; @@ -286,6 +289,60 @@ return fallback; } + async function wakeUpServices(statusCallback) { + const startedAt = Date.now(); + const bases = getApiBaseCandidates(); + const extras = getSiblingServiceOrigins(); + for (const origin of extras) { + fireWakePing(origin); + } + + while (Date.now() - startedAt < WAKEUP_TIMEOUT_MS) { + for (const base of bases) { + const ok = await probeApiBase(base); + if (ok) { + statusCallback && statusCallback("Данные готовы, отображаю обновления..."); + return true; + } + } + const sec = Math.max(1, Math.round((Date.now() - startedAt) / 1000)); + statusCallback && statusCallback(`Подключаю сервисы и собираю ваши данные (${sec}s)...`); + await sleep(WAKEUP_STEP_MS); + } + return false; + } + + async function probeApiBase(base) { + const auth = buildAuth(); + const url = new URL(base + "/api/miniapp/me"); + if (auth.telegramId) { + url.searchParams.set("telegramId", auth.telegramId); + } + try { + const response = await fetchWithTimeout(url.toString(), { headers: auth.headers }, 3500); + return response.ok || response.status === 401 || response.status === 403 || response.status === 400; + } catch { + return false; + } + } + + function getSiblingServiceOrigins() { + return []; + } + + function fireWakePing(origin) { + if (!origin) return; + try { + fetch(origin.replace(/\/+$/, "") + "/actuator/health", { mode: "no-cors", cache: "no-store" }).catch(() => {}); + } catch { + // ignore + } + } + + function sleep(ms) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); + } + async function fetchWithTimeout(resource, init, timeoutMs) { const controller = new AbortController(); const timer = window.setTimeout(() => controller.abort(), timeoutMs); diff --git a/frontend/src/main/resources/static/config.js b/frontend/src/main/resources/static/config.js index 6c6b696..e57d5de 100644 --- a/frontend/src/main/resources/static/config.js +++ b/frontend/src/main/resources/static/config.js @@ -1,29 +1,14 @@ (() => { const host = String(window.location.hostname || "").toLowerCase(); const isLocalHost = host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0"; - const renderApiBase = deriveRenderApiBase(window.location); + const sameOriginApiBase = String(window.location.origin || "").replace(/\/+$/, ""); window.__APP_CONFIG__ = { - apiBaseUrl: isLocalHost ? "http://localhost:8010" : renderApiBase, + apiBaseUrl: isLocalHost ? "http://localhost:8010" : sameOriginApiBase, endpoints: { meetings: ["/api/miniapp/meetings"], tasks: ["/api/miniapp/tasks"], notes: ["/api/miniapp/notes"] } }; - - function deriveRenderApiBase(loc) { - const protocol = String(loc && loc.protocol ? loc.protocol : "https:"); - const hostname = String(loc && loc.hostname ? loc.hostname : ""); - if (!hostname || !hostname.endsWith(".onrender.com")) { - return ""; - } - if (hostname.includes("-frontend.")) { - return `${protocol}//${hostname.replace("-frontend.", "-miniapp-api.")}`; - } - if (hostname.includes("frontend")) { - return `${protocol}//${hostname.replace("frontend", "miniapp-api")}`; - } - return ""; - } })(); diff --git a/frontend/src/main/resources/static/index.html b/frontend/src/main/resources/static/index.html index 498cf6e..a4ca2bb 100644 --- a/frontend/src/main/resources/static/index.html +++ b/frontend/src/main/resources/static/index.html @@ -3,7 +3,7 @@ - AI Chef Schedule + Impera Vox Schedule @@ -20,7 +20,7 @@
-

AiCal

+

Impera Vox

ID
@@ -43,6 +43,7 @@

AiCal

+
all rights reserved. ur impera vox
@@ -50,7 +51,14 @@

AiCal

- +
+ + + +
@@ -73,7 +81,24 @@

AiCal

Длительность (мин) - + + +
diff --git a/frontend/src/main/resources/static/notes.html b/frontend/src/main/resources/static/notes.html index 74e5de2..e7439cd 100644 --- a/frontend/src/main/resources/static/notes.html +++ b/frontend/src/main/resources/static/notes.html @@ -3,7 +3,7 @@ - AiCal Notes + Impera Vox Notes @@ -20,7 +20,7 @@
-

AiCal

+

Impera Vox

ID
@@ -33,18 +33,44 @@

AiCal

-
Заметки
-
- - - -
+
+
Заметки
+ +
+
all rights reserved. ur impera vox
+ + + + diff --git a/frontend/src/main/resources/static/notes.js b/frontend/src/main/resources/static/notes.js index fd962cc..1e98be6 100644 --- a/frontend/src/main/resources/static/notes.js +++ b/frontend/src/main/resources/static/notes.js @@ -2,26 +2,75 @@ const API_REQUEST_TIMEOUT_MS = 7000; const el = { + addBtn: document.getElementById("noteAddBtn"), + modal: document.getElementById("noteModal"), + modalClose: document.getElementById("noteModalClose"), form: document.getElementById("noteForm"), title: document.getElementById("noteTitle"), content: document.getElementById("noteContent"), list: document.getElementById("noteList"), - status: document.getElementById("noteStatus") + status: document.getElementById("noteStatus"), + viewModal: document.getElementById("noteViewModal"), + viewTitle: document.getElementById("noteViewTitle"), + viewContent: document.getElementById("noteViewContent"), + viewClose: document.getElementById("noteViewClose"), + deleteBtn: document.getElementById("noteDeleteBtn") + }; + const state = { + notes: [], + activeNoteId: "" }; init(); function init() { - if (!el.form || !el.list) return; - el.form.addEventListener("submit", onCreate); - loadNotes(); + if (!el.list) return; + + if (el.addBtn) { + el.addBtn.addEventListener("click", openModal); + } + + if (el.modalClose) { + el.modalClose.addEventListener("click", closeModal); + } + + if (el.modal) { + el.modal.addEventListener("click", (e) => { + if (e.target === el.modal) closeModal(); + }); + } + + if (el.form) { + el.form.addEventListener("submit", onCreate); + } + if (el.viewClose) { + el.viewClose.addEventListener("click", closeViewModal); + } + if (el.viewModal) { + el.viewModal.addEventListener("click", (e) => { + if (e.target === el.viewModal) closeViewModal(); + }); + } + if (el.deleteBtn) { + el.deleteBtn.addEventListener("click", onDeleteActiveNote); + } + + bootAndLoadNotes(); + } + + async function bootAndLoadNotes() { + const common = window.AiCalCommon; + if (common && typeof common.wakeUpServices === "function") { + await common.wakeUpServices((msg) => setStatus(msg)); + } + await loadNotes(); } async function onCreate(e) { e.preventDefault(); const title = (el.title.value || "").trim(); const content = (el.content.value || "").trim(); - if (!title || !content) return; + if (!title) return; setStatus("Сохраняю..."); const ok = await requestWithFallback( @@ -38,12 +87,13 @@ } el.form.reset(); + closeModal(); setStatus("Заметка создана"); await loadNotes(); } async function loadNotes() { - setStatus("Загрузка..."); + setStatus("Подготавливаю заметки и подтягиваю последние записи..."); const res = await requestWithFallback(getNoteEndpoints(), [{ method: "GET" }]); if (!res.success) { setStatus(res.message); @@ -51,24 +101,76 @@ return; } - const notes = Array.isArray(res.data) ? res.data : []; - renderNotes(notes); - setStatus(notes.length ? "" : "Пока нет заметок"); + state.notes = Array.isArray(res.data) ? res.data : []; + renderNotes(state.notes); + setStatus(state.notes.length ? "" : "Пока нет заметок"); } function renderNotes(notes) { el.list.innerHTML = ""; for (const n of notes) { const item = document.createElement("div"); - item.className = "page-item"; + item.className = "page-item note-item"; item.innerHTML = `
${escapeHtml(n.title || "(без названия)")}
${escapeHtml(n.content || "")}
`; + item.addEventListener("click", () => openViewModal(n.id)); el.list.appendChild(item); } } + function openModal() { + if (!el.modal) return; + el.modal.classList.remove("hidden"); + if (el.title) { + window.setTimeout(() => el.title.focus(), 0); + } + } + + function closeModal() { + if (!el.modal) return; + el.modal.classList.add("hidden"); + } + + function openViewModal(noteId) { + const note = state.notes.find((n) => String(n.id) === String(noteId)); + if (!note || !el.viewModal) return; + state.activeNoteId = String(note.id || ""); + if (el.viewTitle) { + el.viewTitle.textContent = String(note.title || "Заметка"); + } + if (el.viewContent) { + el.viewContent.textContent = String(note.content || ""); + } + el.viewModal.classList.remove("hidden"); + } + + function closeViewModal() { + if (!el.viewModal) return; + el.viewModal.classList.add("hidden"); + state.activeNoteId = ""; + } + + async function onDeleteActiveNote() { + const id = state.activeNoteId; + if (!id) return; + const ok = window.confirm("Удалить заметку?"); + if (!ok) return; + + setStatus("Удаляю заметку..."); + const res = await requestWithFallback( + getNoteEndpoints().map((base) => `${base}/${id}`), + [{ method: "DELETE" }] + ); + if (!res.success) { + setStatus(res.message); + return; + } + closeViewModal(); + await loadNotes(); + } + async function request(path, init = {}, forcedBase = "") { const common = window.AiCalCommon; const base = forcedBase || (common && common.getApiBaseUrl ? common.getApiBaseUrl() : window.location.origin); @@ -141,7 +243,7 @@ function apiErrorMessage(status) { if (status === 401) return "Нет доступа. Открой Mini App через Telegram"; - if (status === 404) return "API не найден. Проверь apiBaseUrl"; + if (status === 404) return "Сервис заметок прогревается. Попробуйте через несколько секунд."; return `Ошибка API (${status})`; } diff --git a/frontend/src/main/resources/static/profile.html b/frontend/src/main/resources/static/profile.html new file mode 100644 index 0000000..f260004 --- /dev/null +++ b/frontend/src/main/resources/static/profile.html @@ -0,0 +1,70 @@ + + + + + + Impera Vox Profile + + + + + + + + +
+ + +
+
+ +

Impera Vox

+
+
+
ID
+
+
Mr @username
+
+
+
+
+
+ +
+
Профиль
+
+
+
+
Ник
+
-
+
+
+
Префикс
+
-
+
+
+
Часовой пояс
+
-
+
+ + +
+
+
+
Календари
+
+
+
+
+
all rights reserved. ur impera vox
+
+
+ + + + + + + diff --git a/frontend/src/main/resources/static/profile.js b/frontend/src/main/resources/static/profile.js new file mode 100644 index 0000000..f1a6851 --- /dev/null +++ b/frontend/src/main/resources/static/profile.js @@ -0,0 +1,189 @@ +(() => { + const API_REQUEST_TIMEOUT_MS = 7000; + const CALENDAR_VISIBILITY_KEY = "impera_calendar_visibility"; + + const el = { + status: document.getElementById("profileStatus"), + username: document.getElementById("profileUsername"), + prefix: document.getElementById("profilePrefix"), + timezone: document.getElementById("profileTimezone"), + timezoneInput: document.getElementById("timezoneInput"), + timezoneSaveBtn: document.getElementById("timezoneSaveBtn"), + calendarSwitches: document.getElementById("calendarSwitches") + }; + const state = { + profile: null + }; + + init(); + + async function init() { + const common = window.AiCalCommon; + if (common && typeof common.wakeUpServices === "function") { + await common.wakeUpServices((msg) => setStatus(msg)); + } + if (el.timezoneSaveBtn) { + el.timezoneSaveBtn.addEventListener("click", saveTimezone); + } + await loadProfile(); + } + + async function loadProfile() { + setStatus("Подготавливаю профиль..."); + const res = await requestWithFallback(["/api/miniapp/me"], [{ method: "GET" }]); + if (!res.success) { + setStatus(res.message); + return; + } + const data = res.data || {}; + if (el.username) { + const user = data.username ? `@${data.username}` : "-"; + el.username.textContent = user; + } + state.profile = data; + if (el.prefix) { + el.prefix.textContent = data.titlePrefix || "-"; + } + if (el.timezone) { + el.timezone.textContent = data.timezone || "-"; + } + if (el.timezoneInput) { + el.timezoneInput.value = data.timezone || ""; + } + renderCalendarSwitches(data.calendars || {}); + setStatus(""); + } + + function renderCalendarSwitches(calendars) { + if (!el.calendarSwitches) return; + const visibility = readCalendarVisibility(); + const rows = [ + { key: "internal", label: "Internal", sub: "Локальный календарь", connected: calendars.internal !== false }, + { key: "google", label: "Google", sub: calendars.google ? "Подключен" : "Не подключен", connected: !!calendars.google }, + { key: "ical", label: "iCal", sub: calendars.ical ? "Подключен" : "Не подключен", connected: !!calendars.ical } + ]; + el.calendarSwitches.innerHTML = ""; + for (const row of rows) { + const wrap = document.createElement("div"); + wrap.className = "profile-cal-row"; + const info = document.createElement("div"); + info.innerHTML = `
${row.label}
${row.sub}
`; + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = `toggle-switch${(visibility[row.key] ?? row.connected) ? " on" : ""}`; + toggle.disabled = !row.connected; + toggle.title = row.connected ? "Показать/скрыть" : "Источник не подключен"; + toggle.addEventListener("click", () => { + const next = !toggle.classList.contains("on"); + toggle.classList.toggle("on", next); + const current = readCalendarVisibility(); + current[row.key] = next; + saveCalendarVisibility(current); + setStatus("Настройки отображения календарей сохранены"); + }); + wrap.appendChild(info); + wrap.appendChild(toggle); + el.calendarSwitches.appendChild(wrap); + } + } + + async function saveTimezone() { + const value = String(el.timezoneInput && el.timezoneInput.value ? el.timezoneInput.value : "").trim(); + if (!value) { + setStatus("Укажи часовой пояс, например Europe/Moscow"); + return; + } + setStatus("Сохраняю часовой пояс..."); + const res = await requestWithFallback( + ["/api/miniapp/me/timezone"], + [{ method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timezone: value }) }] + ); + if (!res.success) { + setStatus(res.message); + return; + } + if (el.timezone) { + el.timezone.textContent = value; + } + if (state.profile) { + state.profile.timezone = value; + } + setStatus("Часовой пояс обновлен"); + } + + function readCalendarVisibility() { + try { + const parsed = JSON.parse(localStorage.getItem(CALENDAR_VISIBILITY_KEY) || "{}"); + if (!parsed || typeof parsed !== "object") return {}; + return parsed; + } catch { + return {}; + } + } + + function saveCalendarVisibility(data) { + try { + localStorage.setItem(CALENDAR_VISIBILITY_KEY, JSON.stringify(data || {})); + } catch { + // ignore + } + } + + async function request(path, init = {}, forcedBase = "") { + const common = window.AiCalCommon; + const base = forcedBase || (common && common.getApiBaseUrl ? common.getApiBaseUrl() : window.location.origin); + const auth = common && common.buildAuth ? common.buildAuth() : { headers: {}, telegramId: "" }; + const url = new URL(base + path); + if (auth.telegramId) { + url.searchParams.set("telegramId", auth.telegramId); + } + try { + const response = await fetchWithTimeout(url.toString(), { ...init, headers: { ...(init.headers || {}), ...auth.headers } }, API_REQUEST_TIMEOUT_MS); + if (!response.ok) { + return { success: false, status: response.status, message: apiErrorMessage(response.status) }; + } + const data = await response.json().catch(() => null); + return { success: true, data }; + } catch { + return { success: false, message: "Ошибка сети" }; + } + } + + async function requestWithFallback(paths, variants) { + let lastError = { success: false, message: "Ошибка API" }; + const common = window.AiCalCommon; + const bases = common && typeof common.getApiBaseCandidates === "function" + ? common.getApiBaseCandidates() + : [window.location.origin]; + for (const base of bases) { + for (const path of paths) { + for (const variant of variants) { + const res = await request(path, variant, base); + if (res.success) return res; + lastError = res; + } + } + } + return lastError; + } + + function apiErrorMessage(status) { + if (status === 401) return "Нет доступа. Открой Mini App через Telegram"; + if (status === 404) return "Профиль временно недоступен."; + return `Ошибка API (${status})`; + } + + function setStatus(message) { + if (el.status) el.status.textContent = message || ""; + } + + async function fetchWithTimeout(resource, init, timeoutMs) { + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(resource, { ...init, signal: controller.signal }); + } finally { + window.clearTimeout(timer); + } + } +})(); diff --git a/frontend/src/main/resources/static/styles.css b/frontend/src/main/resources/static/styles.css index b4f9fcd..5a533ae 100644 --- a/frontend/src/main/resources/static/styles.css +++ b/frontend/src/main/resources/static/styles.css @@ -166,6 +166,10 @@ body.sidebar-open .sidebar-backdrop { gap: 10px; } +.user-card.profile-link { + cursor: pointer; +} + .avatar { width: 38px; height: 38px; @@ -325,15 +329,19 @@ body.sidebar-open .sidebar-backdrop { cursor: pointer; font: inherit; overflow: hidden; + align-items: flex-start; + justify-content: flex-start; } .meeting-pill-title { - font-size: 11px; - line-height: 1.1; + font-size: 10px; + line-height: 1.05; font-weight: 600; - white-space: nowrap; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; overflow: hidden; - text-overflow: ellipsis; + width: 100%; } .now-time-line { @@ -437,6 +445,43 @@ body.sidebar-open .sidebar-backdrop { gap: 10px; } +.task-tabs { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0; + margin-bottom: 12px; + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; + background: #fff; +} + +.task-tab { + border: 0; + border-right: 1px solid var(--border); + background: #fff; + color: #475569; + padding: 10px 8px; + font: inherit; + font-weight: 700; + text-transform: lowercase; + cursor: pointer; +} + +.task-tab:last-child { + border-right: 0; +} + +.task-tab.active { + background: var(--accent-soft); + color: #1e40af; +} + +.task-tab.drop-target { + background: #eaf1ff; + color: #1d4ed8; +} + .page-item { border: 1px solid var(--border); border-radius: 12px; @@ -447,9 +492,29 @@ body.sidebar-open .sidebar-backdrop { align-items: flex-start; } +.page-item.dragging { + opacity: 0.55; +} + +.page-item.dragging-source { + opacity: 0.2; +} + +.task-touch-ghost { + position: fixed; + z-index: 120; + pointer-events: none; + opacity: 0.95; + box-shadow: 0 14px 32px rgba(2, 6, 23, 0.25); + transform: scale(1.02); +} + .page-item-title { font-size: 16px; font-weight: 600; + overflow-wrap: anywhere; + word-break: break-word; + max-width: 100%; } .page-item-meta { @@ -457,6 +522,41 @@ body.sidebar-open .sidebar-backdrop { color: var(--muted); font-size: 13px; white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + max-width: 100%; +} + +.page-item.note-item { + cursor: pointer; +} + +.page-item.note-item .page-item-meta { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.note-actions { + margin-top: 12px; + display: flex; + justify-content: flex-end; +} + +.note-delete-btn { + width: 34px; + height: 34px; + border: 1px solid #fecaca; + background: #fff1f2; + color: #be123c; + border-radius: 999px; + cursor: pointer; +} + +.page-item-meta.due-urgent { + color: #dc2626; + font-weight: 600; } .task-check { @@ -564,7 +664,15 @@ body.sidebar-open .sidebar-backdrop { border-bottom: 1px solid var(--border); } +.event-head-actions { + display: flex; + align-items: center; + gap: 8px; + position: relative; +} + .event-close-btn, +.event-more-btn, .event-edit-btn { width: 38px; height: 38px; @@ -581,6 +689,42 @@ body.sidebar-open .sidebar-backdrop { background: var(--accent-soft); } +.event-more-menu { + position: absolute; + right: 46px; + top: 44px; + min-width: 160px; + background: #fff; + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 8px 24px rgba(2, 6, 23, 0.14); + overflow: hidden; + z-index: 3; +} + +.event-more-menu.hidden { + display: none; +} + +.event-more-menu button { + width: 100%; + text-align: left; + border: 0; + background: #fff; + padding: 10px 12px; + font: inherit; + font-size: 14px; + color: #334155; +} + +.event-more-menu button + button { + border-top: 1px solid var(--border); +} + +.event-more-menu button.danger { + color: #b91c1c; +} + .event-modal-body { padding: 20px 16px 24px; overflow: auto; @@ -625,11 +769,123 @@ body.sidebar-open .sidebar-backdrop { color: #64748b; } -.event-field input { +.event-field input, +.event-field select { border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; font: inherit; + background: #fff; +} + +.event-color-field input[type="color"] { + padding: 0; + width: 58px; + height: 40px; + border-radius: 8px; +} + +.app-footer { + margin: 0 16px 12px; + border: 1px solid var(--border); + border-radius: 12px; + padding: 8px 12px; + color: #94a3b8; + font-size: 12px; + text-transform: lowercase; + text-align: center; +} + +.profile-grid { + display: grid; + gap: 10px; +} + +.profile-item { + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px; +} + +.profile-item-label { + font-size: 12px; + color: var(--muted); +} + +.profile-item-value { + margin-top: 4px; + font-size: 16px; + font-weight: 600; +} + +.profile-timezone-edit { + margin-top: 10px; + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; +} + +.profile-timezone-edit input { + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 12px; + font: inherit; +} + +.profile-cal-list { + margin-top: 10px; + display: grid; + gap: 8px; +} + +.profile-cal-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + border: 1px solid var(--border); + border-radius: 10px; + padding: 8px 10px; +} + +.profile-cal-name { + font-size: 14px; + font-weight: 600; +} + +.profile-cal-sub { + font-size: 12px; + color: var(--muted); +} + +.toggle-switch { + position: relative; + width: 44px; + height: 24px; + border-radius: 999px; + background: #cbd5e1; + border: 0; + cursor: pointer; +} + +.toggle-switch.on { + background: #3b82f6; +} + +.toggle-switch::after { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 18px; + height: 18px; + border-radius: 999px; + background: #fff; + transition: transform 0.15s ease; +} + +.toggle-switch.on::after { + transform: translateX(20px); } @media (max-width: 980px) { @@ -652,7 +908,7 @@ body.sidebar-open .sidebar-backdrop { .time-label { font-size: 12px; } .user-text .name { font-size: 12px; } .user-text .id { font-size: 11px; } - .meeting-pill-title { font-size: 10px; } + .meeting-pill-title { font-size: 9px; } .event-modal-title { font-size: 22px; } .event-modal-datetime { font-size: 15px; } } diff --git a/frontend/src/main/resources/static/tasks.html b/frontend/src/main/resources/static/tasks.html index bdeda2c..cb34827 100644 --- a/frontend/src/main/resources/static/tasks.html +++ b/frontend/src/main/resources/static/tasks.html @@ -3,7 +3,7 @@ - AiCal Tasks + Impera Vox Tasks @@ -20,7 +20,7 @@
-

AiCal

+

Impera Vox

ID
@@ -37,9 +37,15 @@

AiCal

Задачи
+
+ + + +
+
all rights reserved. ur impera vox
diff --git a/frontend/src/main/resources/static/tasks.js b/frontend/src/main/resources/static/tasks.js index 2ba185d..ae62708 100644 --- a/frontend/src/main/resources/static/tasks.js +++ b/frontend/src/main/resources/static/tasks.js @@ -1,8 +1,12 @@ (() => { const API_REQUEST_TIMEOUT_MS = 7000; + const DELETE_DELAY_SECONDS = 5; + const DAY_MS = 24 * 60 * 60 * 1000; + const IS_TOUCH_DEVICE = ("ontouchstart" in window) || (navigator.maxTouchPoints || 0) > 0; const el = { addBtn: document.getElementById("taskAddBtn"), + tabs: Array.from(document.querySelectorAll(".task-tab")), modal: document.getElementById("taskModal"), modalClose: document.getElementById("taskModalClose"), form: document.getElementById("taskForm"), @@ -13,32 +17,71 @@ status: document.getElementById("taskStatus") }; + const state = { + tasks: [], + activeTab: "active", + draggingTaskId: null, + touchDrag: null + }; + + const pendingDelete = new Map(); + const taskLocks = new Set(); + init(); function init() { if (!el.list) return; if (el.addBtn) { - el.addBtn.addEventListener("click", () => { - openModal(); + el.addBtn.addEventListener("click", openModal); + } + for (const tab of el.tabs) { + tab.addEventListener("click", () => { + const tabName = tab.dataset.tab; + if (!tabName || tabName === state.activeTab) return; + state.activeTab = tabName; + renderTasks(); + }); + tab.addEventListener("dragover", (e) => { + if (!state.draggingTaskId) return; + e.preventDefault(); + tab.classList.add("drop-target"); + }); + tab.addEventListener("dragleave", () => { + tab.classList.remove("drop-target"); + }); + tab.addEventListener("drop", async (e) => { + if (!state.draggingTaskId) return; + e.preventDefault(); + const tabName = tab.dataset.tab; + tab.classList.remove("drop-target"); + if (!tabName) return; + await moveTaskToTab(state.draggingTaskId, tabName); + state.draggingTaskId = null; + renderTasks(); }); } - if (el.modalClose) { el.modalClose.addEventListener("click", closeModal); } - if (el.modal) { el.modal.addEventListener("click", (e) => { if (e.target === el.modal) closeModal(); }); } - if (el.form) { el.form.addEventListener("submit", onCreate); } - loadTasks(); + bootAndLoadTasks(); + } + + async function bootAndLoadTasks() { + const common = window.AiCalCommon; + if (common && typeof common.wakeUpServices === "function") { + await common.wakeUpServices((msg) => setStatus(msg)); + } + await loadTasks(); } async function onCreate(e) { @@ -46,10 +89,15 @@ const title = (el.title.value || "").trim(); if (!title) return; + let dueAt = toOffsetIsoFromInput(el.dueAt.value); + if (!dueAt) { + dueAt = toOffsetIso(new Date(Date.now() + 60 * 60 * 1000)); + } + const payload = { title, priority: el.priority.value || "MEDIUM", - dueAt: toOffsetIsoFromInput(el.dueAt.value) + dueAt }; setStatus("Сохраняю..."); @@ -73,72 +121,91 @@ } async function loadTasks() { - setStatus("Загрузка..."); + clearPendingTimers(); + setStatus("Подготавливаю задачи и синхронизирую изменения..."); const res = await requestWithFallback(getTaskEndpoints(), [{ method: "GET" }]); if (!res.success) { - renderTasks([]); + state.tasks = []; + renderTasks(); setStatus(res.message); return; } - const tasks = Array.isArray(res.data) ? res.data : []; - renderTasks(tasks.filter((t) => !t.completed)); - setStatus(tasks.length ? "" : "Пока нет задач"); + state.tasks = Array.isArray(res.data) ? res.data : []; + renderTasks(); + setStatus(state.tasks.length ? "" : "Пока нет задач"); } - function renderTasks(tasks) { + function renderTasks() { + syncTabs(); el.list.innerHTML = ""; - for (const task of tasks) { + + const filtered = state.tasks.filter((task) => { + if (state.activeTab === "done") { + return task.completed; + } + if (state.activeTab === "backlog") { + return !task.completed && !task.dueAt; + } + return !task.completed && !!task.dueAt; + }); + + for (const task of filtered) { const item = document.createElement("div"); item.className = "page-item"; + item.draggable = !IS_TOUCH_DEVICE; + if (!IS_TOUCH_DEVICE) { + item.addEventListener("dragstart", () => { + state.draggingTaskId = task.id; + item.classList.add("dragging"); + }); + item.addEventListener("dragend", () => { + state.draggingTaskId = null; + item.classList.remove("dragging"); + clearDropTargets(); + }); + } else { + item.addEventListener("touchstart", (e) => onTouchDragStart(e, task.id, item), { passive: true }); + item.addEventListener("touchmove", onTouchDragMove, { passive: false }); + item.addEventListener("touchend", onTouchDragEnd, { passive: false }); + item.addEventListener("touchcancel", onTouchDragCancel, { passive: true }); + } const check = document.createElement("button"); check.type = "button"; check.className = "task-check"; - check.setAttribute("aria-label", "Отметить выполненной"); + check.setAttribute("aria-label", task.completed ? "Вернуть в работу" : "Отметить выполненной"); const text = document.createElement("div"); text.className = "task-text"; - const due = task.dueAt ? formatDateTime(task.dueAt) : "без срока"; - text.innerHTML = ` -
${escapeHtml(task.title || "(без названия)")}
-
${escapeHtml((task.priority || "MEDIUM") + " • " + due)}
- `; - check.addEventListener("click", async () => { - check.disabled = true; + const dueLabel = task.dueAt ? formatDateTime(task.dueAt) : "без срока"; + const meta = document.createElement("div"); + meta.className = "page-item-meta"; + meta.textContent = `${task.priority || "MEDIUM"} • ${dueLabel}`; + if (isDueSoon(task.dueAt) && !task.completed) { + meta.classList.add("due-urgent"); + } + + const title = document.createElement("div"); + title.className = "page-item-title"; + title.textContent = task.title || "(без названия)"; + + text.appendChild(title); + text.appendChild(meta); + + const pending = pendingDelete.get(task.id); + if (task.completed) { check.classList.add("done"); text.classList.add("done"); + } + if (pending) { + check.classList.add("done"); + text.classList.add("done"); + } - const patchRes = await requestWithFallback( - getTaskEndpoints().map((base) => `${base}/${task.id}`), - [ - { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ completed: true }) }, - { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ completed: true }) } - ] - ); - - if (!patchRes.success) { - check.disabled = false; - check.classList.remove("done"); - text.classList.remove("done"); - setStatus(patchRes.message); - return; - } - - setStatus("Задача выполнена. Удалю через 5 секунд..."); - window.setTimeout(async () => { - await requestWithFallback( - getTaskEndpoints().map((base) => `${base}/${task.id}`), - [{ method: "DELETE" }] - ); - item.remove(); - if (!el.list.children.length) { - setStatus("Пока нет задач"); - } else { - setStatus(""); - } - }, 5000); + check.addEventListener("click", async () => { + await onTaskToggle(task.id); }); item.appendChild(check); @@ -147,6 +214,255 @@ } } + async function onTaskToggle(taskId) { + if (!taskId || taskLocks.has(taskId)) return; + const task = state.tasks.find((t) => t.id === taskId); + if (!task) return; + + const pending = pendingDelete.get(taskId); + if (pending) { + await cancelPendingDelete(task); + return; + } + if (task.completed) { + await markTaskCompleted(task, false); + setStatus("Задача возвращена в работу"); + return; + } + + const ok = await markTaskCompleted(task, true); + if (!ok) return; + startDeleteCountdown(task); + } + + async function moveTaskToTab(taskId, tabName) { + const task = state.tasks.find((t) => t.id === taskId); + if (!task || taskLocks.has(taskId)) return; + + if (tabName === "done") { + const ok = await markTaskCompleted(task, true); + if (ok) setStatus("Задача перемещена в done"); + return; + } + + if (tabName === "backlog") { + const ok = await patchTask(task.id, { completed: false, clearDueAt: true }); + if (!ok.success) { + setStatus(ok.message); + return; + } + task.completed = false; + task.dueAt = null; + setStatus("Задача перемещена в backlog"); + return; + } + + if (tabName === "active") { + const nextDue = task.dueAt || toOffsetIso(new Date(Date.now() + 60 * 60 * 1000)); + const ok = await patchTask(task.id, { completed: false, dueAt: nextDue }); + if (!ok.success) { + setStatus(ok.message); + return; + } + task.completed = false; + task.dueAt = nextDue; + setStatus("Задача перемещена в active"); + } + } + + async function markTaskCompleted(task, completed) { + taskLocks.add(task.id); + setStatus(completed ? "Отмечаю выполненной..." : "Возвращаю в работу..."); + + const patchRes = await patchTask(task.id, { completed }); + + taskLocks.delete(task.id); + if (!patchRes.success) { + setStatus(patchRes.message); + return false; + } + + task.completed = completed; + renderTasks(); + return true; + } + + async function patchTask(taskId, payload) { + return requestWithFallback( + getTaskEndpoints().map((base) => `${base}/${taskId}`), + [ + { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }, + { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) } + ] + ); + } + + function startDeleteCountdown(task) { + let remaining = DELETE_DELAY_SECONDS; + setStatus(`Задача выполнена. Удалю через ${remaining}... Нажмите еще раз, чтобы отменить.`); + + const intervalId = window.setInterval(() => { + remaining -= 1; + if (remaining > 0) { + setStatus(`Задача выполнена. Удалю через ${remaining}... Нажмите еще раз, чтобы отменить.`); + } + }, 1000); + + const timeoutId = window.setTimeout(async () => { + window.clearInterval(intervalId); + pendingDelete.delete(task.id); + + const delRes = await requestWithFallback( + getTaskEndpoints().map((base) => `${base}/${task.id}`), + [{ method: "DELETE" }] + ); + + if (!delRes.success) { + setStatus(delRes.message); + return; + } + + state.tasks = state.tasks.filter((t) => t.id !== task.id); + renderTasks(); + setStatus(state.tasks.length ? "" : "Пока нет задач"); + }, DELETE_DELAY_SECONDS * 1000); + + pendingDelete.set(task.id, { timeoutId, intervalId }); + renderTasks(); + } + + async function cancelPendingDelete(task) { + const pending = pendingDelete.get(task.id); + if (!pending) return; + + window.clearTimeout(pending.timeoutId); + window.clearInterval(pending.intervalId); + pendingDelete.delete(task.id); + + const ok = await markTaskCompleted(task, false); + if (!ok) return; + setStatus("Удаление отменено"); + } + + function clearPendingTimers() { + for (const pending of pendingDelete.values()) { + window.clearTimeout(pending.timeoutId); + window.clearInterval(pending.intervalId); + } + pendingDelete.clear(); + } + + function syncTabs() { + const counts = { + active: state.tasks.filter((t) => !t.completed && !!t.dueAt).length, + done: state.tasks.filter((t) => t.completed).length, + backlog: state.tasks.filter((t) => !t.completed && !t.dueAt).length + }; + + for (const tab of el.tabs) { + const tabName = tab.dataset.tab; + if (!tabName) continue; + tab.classList.toggle("active", tabName === state.activeTab); + tab.textContent = `${tabName} ${counts[tabName] || 0}`; + } + } + + function clearDropTargets() { + for (const tab of el.tabs) { + tab.classList.remove("drop-target"); + } + } + + function onTouchDragStart(e, taskId, item) { + if (!e.touches || e.touches.length !== 1) return; + const target = e.target; + if (target instanceof HTMLElement && target.closest(".task-check")) { + return; + } + const touch = e.touches[0]; + const rect = item.getBoundingClientRect(); + const ghost = item.cloneNode(true); + ghost.classList.add("task-touch-ghost"); + ghost.style.width = `${rect.width}px`; + ghost.style.height = `${rect.height}px`; + ghost.style.left = `${rect.left}px`; + ghost.style.top = `${rect.top}px`; + document.body.appendChild(ghost); + + item.classList.add("dragging-source"); + state.touchDrag = { + taskId, + sourceEl: item, + ghostEl: ghost, + offsetX: touch.clientX - rect.left, + offsetY: touch.clientY - rect.top, + moved: false + }; + } + + function onTouchDragMove(e) { + if (!state.touchDrag || !e.touches || e.touches.length !== 1) return; + e.preventDefault(); + const touch = e.touches[0]; + const drag = state.touchDrag; + drag.moved = true; + drag.ghostEl.style.left = `${touch.clientX - drag.offsetX}px`; + drag.ghostEl.style.top = `${touch.clientY - drag.offsetY}px`; + + const tab = findTabByPoint(touch.clientX, touch.clientY); + clearDropTargets(); + if (tab) { + tab.classList.add("drop-target"); + } + } + + async function onTouchDragEnd(e) { + if (!state.touchDrag) return; + e.preventDefault(); + const changed = e.changedTouches && e.changedTouches[0] ? e.changedTouches[0] : null; + const drag = state.touchDrag; + const tab = changed ? findTabByPoint(changed.clientX, changed.clientY) : null; + const tabName = tab && tab.dataset ? tab.dataset.tab : ""; + + cleanupTouchDrag(); + + if (!tabName || !drag.taskId) return; + await moveTaskToTab(drag.taskId, tabName); + renderTasks(); + } + + function onTouchDragCancel() { + if (!state.touchDrag) return; + cleanupTouchDrag(); + } + + function cleanupTouchDrag() { + const drag = state.touchDrag; + if (!drag) return; + if (drag.ghostEl && drag.ghostEl.parentNode) { + drag.ghostEl.parentNode.removeChild(drag.ghostEl); + } + if (drag.sourceEl) { + drag.sourceEl.classList.remove("dragging-source"); + } + clearDropTargets(); + state.touchDrag = null; + } + + function findTabByPoint(clientX, clientY) { + const target = document.elementFromPoint(clientX, clientY); + if (!(target instanceof HTMLElement)) return null; + return target.closest(".task-tab"); + } + + function isDueSoon(value) { + if (!value) return false; + const due = new Date(value); + if (Number.isNaN(due.getTime())) return false; + const diff = due.getTime() - Date.now(); + return diff > 0 && diff <= DAY_MS; + } + function openModal() { if (!el.modal) return; el.modal.classList.remove("hidden"); @@ -232,7 +548,7 @@ function apiErrorMessage(status) { if (status === 401) return "Нет доступа. Открой Mini App через Telegram"; - if (status === 404) return "API не найден. Проверь apiBaseUrl"; + if (status === 404) return "Сервис задач поднимается. Данные скоро появятся."; return `Ошибка API (${status})`; } @@ -274,13 +590,18 @@ return String(n).padStart(2, "0"); } - function escapeHtml(s) { - return String(s) - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); + function toOffsetIso(date) { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) return null; + const y = date.getFullYear(); + const m = pad2(date.getMonth() + 1); + const d = pad2(date.getDate()); + const hh = pad2(date.getHours()); + const mm = pad2(date.getMinutes()); + const ss = pad2(date.getSeconds()); + const off = -date.getTimezoneOffset(); + const sign = off >= 0 ? "+" : "-"; + const abs = Math.abs(off); + return `${y}-${m}-${d}T${hh}:${mm}:${ss}${sign}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}`; } async function fetchWithTimeout(resource, init, timeoutMs) { diff --git a/miniapp-backend/src/main/resources/application.yml b/miniapp-backend/src/main/resources/application.yml index 1bf34ad..8fea6a3 100644 --- a/miniapp-backend/src/main/resources/application.yml +++ b/miniapp-backend/src/main/resources/application.yml @@ -50,6 +50,7 @@ app: webhook-path: ${TELEGRAM_WEBHOOK_PATH:/api/telegram/webhook} api-base: ${TELEGRAM_API_BASE:https://api.telegram.org} public-base-url: ${APP_PUBLIC_BASE_URL:http://localhost:8010} + use-webhook: ${TELEGRAM_USE_WEBHOOK:false} poll-interval-ms: ${TELEGRAM_POLL_INTERVAL_MS:3000} processing-threads: ${TELEGRAM_PROCESSING_THREADS:2} processing-queue-capacity: ${TELEGRAM_PROCESSING_QUEUE_CAPACITY:200} diff --git a/render.yaml b/render.yaml index ed9153e..d7960d7 100644 --- a/render.yaml +++ b/render.yaml @@ -81,6 +81,8 @@ services: sync: false - key: TELEGRAM_WEBHOOK_PATH value: /api/telegram/webhook + - key: TELEGRAM_USE_WEBHOOK + value: "false" - key: TELEGRAM_API_BASE value: https://api.telegram.org - key: APP_PUBLIC_BASE_URL diff --git a/telegram-backend/src/main/resources/application.yml b/telegram-backend/src/main/resources/application.yml index c54c685..261484b 100644 --- a/telegram-backend/src/main/resources/application.yml +++ b/telegram-backend/src/main/resources/application.yml @@ -50,6 +50,7 @@ app: webhook-path: ${TELEGRAM_WEBHOOK_PATH:/api/telegram/webhook} api-base: ${TELEGRAM_API_BASE:https://api.telegram.org} public-base-url: ${APP_PUBLIC_BASE_URL:} + use-webhook: ${TELEGRAM_USE_WEBHOOK:false} poll-interval-ms: ${TELEGRAM_POLL_INTERVAL_MS:3000} processing-threads: ${TELEGRAM_PROCESSING_THREADS:4} processing-queue-capacity: ${TELEGRAM_PROCESSING_QUEUE_CAPACITY:500}