feat: cancel invitations for both attendees and organisers

This commit is contained in:
sBubshait 2025-07-22 15:45:58 +03:00
parent 05e9261f62
commit 78ef21d2b7
8 changed files with 277 additions and 18 deletions

View File

@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import online.wesal.wesal.dto.AcceptInvitationRequest; import online.wesal.wesal.dto.AcceptInvitationRequest;
import online.wesal.wesal.dto.ApiResponse; import online.wesal.wesal.dto.ApiResponse;
import online.wesal.wesal.dto.CancelInvitationRequest;
import online.wesal.wesal.dto.CategorizedInvitationsResponse; import online.wesal.wesal.dto.CategorizedInvitationsResponse;
import online.wesal.wesal.dto.CreateInvitationRequest; import online.wesal.wesal.dto.CreateInvitationRequest;
import online.wesal.wesal.dto.InvitationResponse; 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")); 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<ApiResponse<Void>> 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"));
}
}
} }

View File

@ -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;
}
}

View File

@ -11,4 +11,8 @@ public interface AttendeeRepository extends JpaRepository<Attendee, Long> {
boolean existsByInvitationIdAndUserId(Long invitationId, Long userId); boolean existsByInvitationIdAndUserId(Long invitationId, Long userId);
List<Attendee> findByInvitationId(Long invitationId); List<Attendee> findByInvitationId(Long invitationId);
void deleteByInvitationId(Long invitationId);
void deleteByInvitationIdAndUserId(Long invitationId, Long userId);
} }

View File

@ -15,6 +15,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -120,6 +121,39 @@ public class InvitationService {
invitationRepository.save(invitation); 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) { private InvitationResponse mapToResponse(Invitation invitation) {
InvitationResponse response = new InvitationResponse(); InvitationResponse response = new InvitationResponse();
response.setId(invitation.getId()); response.setId(invitation.getId());

View File

@ -9,6 +9,7 @@ class ApiConstants {
static const String updateUserEndpoint = '/updateUser'; static const String updateUserEndpoint = '/updateUser';
// Invitation endpoints // Invitation endpoints
static const String invitationsEndpoint = '/invitations';
static const String getAllInvitationsEndpoint = '/invitations/all'; static const String getAllInvitationsEndpoint = '/invitations/all';
static const String acceptInvitationEndpoint = '/invitations/accept'; static const String acceptInvitationEndpoint = '/invitations/accept';
static const String createInvitationEndpoint = '/invitations/create'; static const String createInvitationEndpoint = '/invitations/create';

View File

@ -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<String, dynamic> json) {
return InvitationAttendee(
id: json['id'],
displayName: json['displayName'],
avatar: json['avatar'],
joinedAt: DateTime.parse(json['joinedAt']),
);
}
}
class Invitation { class Invitation {
final int id; final int id;
final String title; final String title;
@ -129,3 +152,56 @@ class InvitationsData {
bool get isEmpty => created.isEmpty && accepted.isEmpty && available.isEmpty; 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<InvitationAttendee> 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<String, dynamic> 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';
}
}

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../services/invitations_service.dart'; import '../../services/invitations_service.dart';
import '../../models/invitation_models.dart'; import '../../models/invitation_models.dart';
import '../../utils/invitation_utils.dart'; import '../../utils/invitation_utils.dart';
import 'invitation_details_page.dart';
class InvitationsPage extends StatefulWidget { class InvitationsPage extends StatefulWidget {
@override @override
@ -143,6 +144,22 @@ class _InvitationsPageState extends State<InvitationsPage>
} }
} }
Future<void> _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) { Widget _buildInvitationCard(Invitation invitation, String section) {
final invitationKey = invitation.id.toString(); final invitationKey = invitation.id.toString();
bool isOwned = section == 'created'; bool isOwned = section == 'created';
@ -305,20 +322,24 @@ class _InvitationsPageState extends State<InvitationsPage>
width: double.infinity, width: double.infinity,
height: 44, height: 44,
child: ElevatedButton( child: ElevatedButton(
onPressed: isOwned || isAccepted || isAccepting || hasBeenAccepted onPressed: isAccepting
? null ? null
: () { : (isOwned || isAccepted || hasBeenAccepted)
_acceptInvitation(invitation); ? () {
}, _navigateToInvitationDetails(invitation, isOwned);
}
: () {
_acceptInvitation(invitation);
},
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: (isAccepted || hasBeenAccepted) backgroundColor: (isAccepted || hasBeenAccepted)
? Colors.green ? Colors.green
: (isOwned ? Colors.grey[400] : Color(0xFF6A4C93)), : (isOwned ? Color(0xFF6A4C93) : Color(0xFF6A4C93)),
foregroundColor: Colors.white, foregroundColor: Colors.white,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
elevation: isOwned || isAccepted || hasBeenAccepted ? 0 : 2, elevation: 2,
), ),
child: isAccepting child: isAccepting
? SizedBox( ? SizedBox(
@ -335,21 +356,18 @@ class _InvitationsPageState extends State<InvitationsPage>
builder: (context, child) { builder: (context, child) {
if (animController.value < 0.5) { if (animController.value < 0.5) {
return Text( return Text(
'Accepted ✓', 'View',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
); );
} else { } else {
return Opacity( return Text(
opacity: 1.0 - ((animController.value - 0.5) * 2), 'View',
child: Text( style: TextStyle(
'Accepted ✓', fontSize: 16,
style: TextStyle( fontWeight: FontWeight.w600,
fontSize: 16,
fontWeight: FontWeight.w600,
),
), ),
); );
} }
@ -357,8 +375,8 @@ class _InvitationsPageState extends State<InvitationsPage>
) )
: Text( : Text(
(isAccepted || hasBeenAccepted) (isAccepted || hasBeenAccepted)
? 'Accepted ✓' ? 'View'
: (isOwned ? 'View/Edit' : 'Accept Invite'), : (isOwned ? 'View' : 'Accept Invite'),
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,

View File

@ -131,4 +131,87 @@ class InvitationsService {
}; };
} }
} }
static Future<Map<String, dynamic>> 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<Map<String, dynamic>> 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.',
};
}
}
} }