feat: cancel invitations for both attendees and organisers
This commit is contained in:
parent
05e9261f62
commit
78ef21d2b7
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
@ -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());
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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;
|
||||||
@ -128,4 +151,57 @@ 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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user