diff --git a/backend/src/main/java/online/wesal/wesal/controller/InvitationController.java b/backend/src/main/java/online/wesal/wesal/controller/InvitationController.java index 7dc2058..425fcfc 100644 --- a/backend/src/main/java/online/wesal/wesal/controller/InvitationController.java +++ b/backend/src/main/java/online/wesal/wesal/controller/InvitationController.java @@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import online.wesal.wesal.dto.AcceptInvitationRequest; import online.wesal.wesal.dto.ApiResponse; +import online.wesal.wesal.dto.CancelInvitationRequest; import online.wesal.wesal.dto.CategorizedInvitationsResponse; import online.wesal.wesal.dto.CreateInvitationRequest; import online.wesal.wesal.dto.InvitationResponse; @@ -112,4 +113,21 @@ public class InvitationController { return ResponseEntity.status(500).body(ApiResponse.error("Something went wrong.. We're sorry but try again later")); } } + + @PostMapping("/cancel") + @Operation(summary = "Cancel invitation", description = "Cancel an invitation - attendee leaves or organizer deletes entire invitation") + public ResponseEntity> cancelInvitation( + @Valid @RequestBody CancelInvitationRequest request, + Authentication authentication) { + + try { + String userEmail = authentication.getName(); + invitationService.cancelInvitation(request.getId(), userEmail); + return ResponseEntity.ok(new ApiResponse<>(true)); + } catch (RuntimeException e) { + return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())); + } catch (Exception e) { + return ResponseEntity.status(500).body(ApiResponse.error("Something went wrong.. We're sorry but try again later")); + } + } } \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/dto/CancelInvitationRequest.java b/backend/src/main/java/online/wesal/wesal/dto/CancelInvitationRequest.java new file mode 100644 index 0000000..0b71766 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/dto/CancelInvitationRequest.java @@ -0,0 +1,25 @@ +package online.wesal.wesal.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +public class CancelInvitationRequest { + + @NotNull(message = "Invitation ID is required") + @Positive(message = "Invitation ID must be positive") + private Long id; + + public CancelInvitationRequest() {} + + public CancelInvitationRequest(Long id) { + this.id = id; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/repository/AttendeeRepository.java b/backend/src/main/java/online/wesal/wesal/repository/AttendeeRepository.java index a761d6f..2aa1819 100644 --- a/backend/src/main/java/online/wesal/wesal/repository/AttendeeRepository.java +++ b/backend/src/main/java/online/wesal/wesal/repository/AttendeeRepository.java @@ -11,4 +11,8 @@ public interface AttendeeRepository extends JpaRepository { boolean existsByInvitationIdAndUserId(Long invitationId, Long userId); List findByInvitationId(Long invitationId); + + void deleteByInvitationId(Long invitationId); + + void deleteByInvitationIdAndUserId(Long invitationId, Long userId); } \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/service/InvitationService.java b/backend/src/main/java/online/wesal/wesal/service/InvitationService.java index 81ef294..57fbbdb 100644 --- a/backend/src/main/java/online/wesal/wesal/service/InvitationService.java +++ b/backend/src/main/java/online/wesal/wesal/service/InvitationService.java @@ -15,6 +15,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -120,6 +121,39 @@ public class InvitationService { invitationRepository.save(invitation); } + @Transactional + public void cancelInvitation(Long invitationId, String userEmail) { + User user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("Authentication error. Please log in again")); + + Invitation invitation = invitationRepository.findById(invitationId) + .orElse(null); + + if (invitation == null) { + throw new RuntimeException("Invitation not found or already cancelled by organizer"); + } + + if (invitation.getDateTime() != null && invitation.getDateTime().isBefore(LocalDateTime.now())) { + throw new RuntimeException("Cannot cancel expired invitation"); + } + + boolean isOrganizer = invitation.getCreator().getId().equals(user.getId()); + boolean isAttendee = attendeeRepository.existsByInvitationIdAndUserId(invitationId, user.getId()); + + if (!isOrganizer && !isAttendee) { + throw new RuntimeException("You are not associated with this invitation"); + } + + if (isOrganizer) { + attendeeRepository.deleteByInvitationId(invitationId); + invitationRepository.deleteById(invitationId); + } else { + attendeeRepository.deleteByInvitationIdAndUserId(invitationId, user.getId()); + invitation.setCurrentAttendees(invitation.getCurrentAttendees() - 1); + invitationRepository.save(invitation); + } + } + private InvitationResponse mapToResponse(Invitation invitation) { InvitationResponse response = new InvitationResponse(); response.setId(invitation.getId()); diff --git a/frontend/lib/constants/api_constants.dart b/frontend/lib/constants/api_constants.dart index bb73cfb..355cdda 100644 --- a/frontend/lib/constants/api_constants.dart +++ b/frontend/lib/constants/api_constants.dart @@ -9,6 +9,7 @@ class ApiConstants { static const String updateUserEndpoint = '/updateUser'; // Invitation endpoints + static const String invitationsEndpoint = '/invitations'; static const String getAllInvitationsEndpoint = '/invitations/all'; static const String acceptInvitationEndpoint = '/invitations/accept'; static const String createInvitationEndpoint = '/invitations/create'; diff --git a/frontend/lib/models/invitation_models.dart b/frontend/lib/models/invitation_models.dart index 973ef72..1857afc 100644 --- a/frontend/lib/models/invitation_models.dart +++ b/frontend/lib/models/invitation_models.dart @@ -41,6 +41,29 @@ class InvitationCreator { } } +class InvitationAttendee { + final int id; + final String displayName; + final String? avatar; + final DateTime joinedAt; + + InvitationAttendee({ + required this.id, + required this.displayName, + this.avatar, + required this.joinedAt, + }); + + factory InvitationAttendee.fromJson(Map json) { + return InvitationAttendee( + id: json['id'], + displayName: json['displayName'], + avatar: json['avatar'], + joinedAt: DateTime.parse(json['joinedAt']), + ); + } +} + class Invitation { final int id; final String title; @@ -128,4 +151,57 @@ class InvitationsData { } bool get isEmpty => created.isEmpty && accepted.isEmpty && available.isEmpty; +} + +class InvitationDetails { + final int id; + final String title; + final String? description; + final DateTime? dateTime; + final String? location; + final int maxParticipants; + final int currentAttendees; + final InvitationTag tag; + final InvitationCreator creator; + final DateTime createdAt; + final List attendees; + + InvitationDetails({ + required this.id, + required this.title, + this.description, + this.dateTime, + this.location, + required this.maxParticipants, + required this.currentAttendees, + required this.tag, + required this.creator, + required this.createdAt, + required this.attendees, + }); + + factory InvitationDetails.fromJson(Map json) { + return InvitationDetails( + id: json['id'], + title: json['title'], + description: json['description'], + dateTime: json['dateTime'] != null ? DateTime.parse(json['dateTime']) : null, + location: json['location'], + maxParticipants: json['maxParticipants'], + currentAttendees: json['currentAttendees'], + tag: InvitationTag.fromJson(json['tag']), + creator: InvitationCreator.fromJson(json['creator']), + createdAt: DateTime.parse(json['createdAt']), + attendees: (json['attendees'] as List) + .map((item) => InvitationAttendee.fromJson(item)) + .toList(), + ); + } + + String get status { + if (currentAttendees >= maxParticipants) { + return 'Full'; + } + return 'Available'; + } } \ No newline at end of file diff --git a/frontend/lib/screens/pages/invitations_page.dart b/frontend/lib/screens/pages/invitations_page.dart index add8de5..c1e5057 100644 --- a/frontend/lib/screens/pages/invitations_page.dart +++ b/frontend/lib/screens/pages/invitations_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../services/invitations_service.dart'; import '../../models/invitation_models.dart'; import '../../utils/invitation_utils.dart'; +import 'invitation_details_page.dart'; class InvitationsPage extends StatefulWidget { @override @@ -143,6 +144,22 @@ class _InvitationsPageState extends State } } + Future _navigateToInvitationDetails(Invitation invitation, bool isOwner) async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => InvitationDetailsPage( + invitationId: invitation.id, + isOwner: isOwner, + ), + ), + ); + + if (result == true) { + _loadInvitations(); + } + } + Widget _buildInvitationCard(Invitation invitation, String section) { final invitationKey = invitation.id.toString(); bool isOwned = section == 'created'; @@ -305,20 +322,24 @@ class _InvitationsPageState extends State width: double.infinity, height: 44, child: ElevatedButton( - onPressed: isOwned || isAccepted || isAccepting || hasBeenAccepted - ? null - : () { - _acceptInvitation(invitation); - }, + onPressed: isAccepting + ? null + : (isOwned || isAccepted || hasBeenAccepted) + ? () { + _navigateToInvitationDetails(invitation, isOwned); + } + : () { + _acceptInvitation(invitation); + }, style: ElevatedButton.styleFrom( backgroundColor: (isAccepted || hasBeenAccepted) ? Colors.green - : (isOwned ? Colors.grey[400] : Color(0xFF6A4C93)), + : (isOwned ? Color(0xFF6A4C93) : Color(0xFF6A4C93)), foregroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), - elevation: isOwned || isAccepted || hasBeenAccepted ? 0 : 2, + elevation: 2, ), child: isAccepting ? SizedBox( @@ -335,21 +356,18 @@ class _InvitationsPageState extends State builder: (context, child) { if (animController.value < 0.5) { return Text( - 'Accepted ✓', + 'View', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, ), ); } else { - return Opacity( - opacity: 1.0 - ((animController.value - 0.5) * 2), - child: Text( - 'Accepted ✓', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), + return Text( + 'View', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, ), ); } @@ -357,8 +375,8 @@ class _InvitationsPageState extends State ) : Text( (isAccepted || hasBeenAccepted) - ? 'Accepted ✓' - : (isOwned ? 'View/Edit' : 'Accept Invite'), + ? 'View' + : (isOwned ? 'View' : 'Accept Invite'), style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, diff --git a/frontend/lib/services/invitations_service.dart b/frontend/lib/services/invitations_service.dart index 248cd21..fe1a3a2 100644 --- a/frontend/lib/services/invitations_service.dart +++ b/frontend/lib/services/invitations_service.dart @@ -131,4 +131,87 @@ class InvitationsService { }; } } + + static Future> getInvitationDetails(int invitationId) async { + try { + final response = await HttpService.get( + '${ApiConstants.invitationsEndpoint}/get?id=$invitationId', + ); + + if (response.statusCode == 200) { + final responseData = jsonDecode(response.body); + if (responseData['status'] == true) { + final invitationDetails = InvitationDetails.fromJson(responseData['data']); + return { + 'success': true, + 'data': invitationDetails, + }; + } else { + return { + 'success': false, + 'message': responseData['message'] ?? 'Failed to get invitation details', + }; + } + } else if (response.statusCode == 401) { + return { + 'success': false, + 'message': 'Session expired. Please login again.', + }; + } else if (response.statusCode == 403) { + return { + 'success': false, + 'message': 'Access denied. Invalid credentials.', + }; + } else { + return { + 'success': false, + 'message': 'Server error (${response.statusCode})', + }; + } + } catch (e) { + print('Error getting invitation details: $e'); + return { + 'success': false, + 'message': 'Network error. Please check your connection.', + }; + } + } + + static Future> cancelInvitation(int invitationId) async { + try { + final response = await HttpService.post( + '${ApiConstants.invitationsEndpoint}/cancel', + {'id': invitationId}, + ); + + if (response.statusCode == 200) { + final responseData = jsonDecode(response.body); + return { + 'success': responseData['status'] ?? false, + 'message': responseData['message'] ?? 'Request completed', + }; + } else if (response.statusCode == 401) { + return { + 'success': false, + 'message': 'Session expired. Please login again.', + }; + } else if (response.statusCode == 403) { + return { + 'success': false, + 'message': 'Access denied. Invalid credentials.', + }; + } else { + return { + 'success': false, + 'message': 'Server error (${response.statusCode})', + }; + } + } catch (e) { + print('Error canceling invitation: $e'); + return { + 'success': false, + 'message': 'Network error. Please check your connection.', + }; + } + } }