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 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"));
}
}
}

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);
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.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());

View File

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

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 {
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';
}
}

View File

@ -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,

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.',
};
}
}
}