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 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<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);
|
||||
|
||||
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.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());
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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 {
|
||||
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<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 '../../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<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) {
|
||||
final invitationKey = invitation.id.toString();
|
||||
bool isOwned = section == 'created';
|
||||
@ -305,20 +322,24 @@ class _InvitationsPageState extends State<InvitationsPage>
|
||||
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<InvitationsPage>
|
||||
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<InvitationsPage>
|
||||
)
|
||||
: Text(
|
||||
(isAccepted || hasBeenAccepted)
|
||||
? 'Accepted ✓'
|
||||
: (isOwned ? 'View/Edit' : 'Accept Invite'),
|
||||
? 'View'
|
||||
: (isOwned ? 'View' : 'Accept Invite'),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
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