feat: prevent duplicate membership applications and fees
- Add UNIQUE constraint on membership_application.user_id (one app per user) - Add UNIQUE constraint on membership_fees.user_id (one fee record per user) - Add validation checks in process_application.php before inserting - Improve error messages for duplicate submission attempts - Add migration script to clean up existing duplicates before constraints - Update checkMembershipApplication to set session message on redirect - Add comprehensive documentation of duplicate prevention architecture Individual payments/EFTs are tracked separately in payments table
This commit is contained in:
86
docs/MEMBERSHIP_DUPLICATE_PREVENTION.md
Normal file
86
docs/MEMBERSHIP_DUPLICATE_PREVENTION.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Membership Application Duplicate Prevention
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Implemented comprehensive validation to prevent users from submitting multiple membership applications or creating multiple membership fee records. Each user can have exactly one application and one membership fee record. Individual payments are tracked separately in the payments/efts table.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
User (1) ---> Membership Application (1) ---> Membership Fee (1) ---> Multiple Payments/EFTs
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Membership Application**: Stores user details and application information (one per user)
|
||||||
|
- **Membership Fee**: Stores the total fee amount and dates (one per user, linked to application)
|
||||||
|
- **Payments/EFTs**: Tracks individual payment transactions for the membership fee (many per fee)
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### 1. Database Level Protection
|
||||||
|
**File:** `docs/migrations/002_add_unique_constraints_membership.sql`
|
||||||
|
|
||||||
|
- Added `UNIQUE` constraint on `membership_application.user_id` - ensures each user can only have one application
|
||||||
|
- Added `UNIQUE` constraint on `membership_fees.user_id` - ensures each user can only have one membership fee record
|
||||||
|
- Cleans up any duplicate records before adding constraints
|
||||||
|
|
||||||
|
### 2. Application Level Validation
|
||||||
|
**File:** `src/processors/process_application.php`
|
||||||
|
|
||||||
|
Added pre-submission checks:
|
||||||
|
- Check if user already has a membership application in the database
|
||||||
|
- Check if user already has a membership fee record
|
||||||
|
- Return clear error message if either check fails
|
||||||
|
- Catch database constraint violations and provide user-friendly message
|
||||||
|
|
||||||
|
**File:** `src/config/functions.php`
|
||||||
|
|
||||||
|
- Improved `checkMembershipApplication()` to set session message before redirecting
|
||||||
|
- Message displayed: "You have already submitted a membership application."
|
||||||
|
|
||||||
|
### 3. Error Handling
|
||||||
|
If a user somehow bypasses checks:
|
||||||
|
- Server validates before processing
|
||||||
|
- Returns HTTP 400 error with JSON response
|
||||||
|
- User sees clear message directing them to support or check email
|
||||||
|
- Database constraints prevent data corruption (duplicate key violation)
|
||||||
|
|
||||||
|
## User Flow
|
||||||
|
|
||||||
|
1. **First Visit to Application Page:**
|
||||||
|
- `checkMembershipApplication()` checks database
|
||||||
|
- If no application exists, shows form
|
||||||
|
- If application exists, redirects to `membership_details.php`
|
||||||
|
|
||||||
|
2. **Form Submission:**
|
||||||
|
- Server checks for existing application
|
||||||
|
- Server checks for existing membership fee
|
||||||
|
- If checks pass, inserts application and fee in transaction
|
||||||
|
- On success, redirects to indemnity page
|
||||||
|
- On error, returns JSON error response
|
||||||
|
|
||||||
|
3. **Payment Process:**
|
||||||
|
- Individual payment records are created in payments/efts table
|
||||||
|
- Multiple payments can be made against the single membership_fee record
|
||||||
|
- Payment status is tracked independently from application
|
||||||
|
|
||||||
|
## Testing Recommendations
|
||||||
|
|
||||||
|
1. Test creating a membership application - should succeed
|
||||||
|
2. Try applying again - should be redirected to membership_details
|
||||||
|
3. Try submitting the form multiple times rapidly - should fail on 2nd attempt
|
||||||
|
4. Verify payments can be made against the single membership fee record
|
||||||
|
5. Check database constraints: `SHOW INDEX FROM membership_application;` and `SHOW INDEX FROM membership_fees;`
|
||||||
|
|
||||||
|
## Database Constraints
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- One application per user
|
||||||
|
ALTER TABLE membership_application
|
||||||
|
ADD CONSTRAINT uk_membership_application_user_id UNIQUE (user_id);
|
||||||
|
|
||||||
|
-- One membership fee record per user
|
||||||
|
ALTER TABLE membership_fees
|
||||||
|
ADD CONSTRAINT uk_membership_fees_user_id UNIQUE (user_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backwards Compatibility
|
||||||
|
The migration script cleans up any existing duplicate records before adding constraints, ensuring no data loss.
|
||||||
37
docs/migrations/002_add_unique_constraints_membership.sql
Normal file
37
docs/migrations/002_add_unique_constraints_membership.sql
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
-- Migration: Add UNIQUE constraints to prevent duplicate membership applications and fees
|
||||||
|
-- Date: 2025-12-05
|
||||||
|
-- Purpose: Ensure each user can only have one application and one membership fee record
|
||||||
|
-- Note: Individual payments are tracked in the payments/efts table, not here
|
||||||
|
|
||||||
|
-- Add UNIQUE constraint to membership_application table
|
||||||
|
-- First, delete any duplicate applications keeping the most recent one
|
||||||
|
DELETE FROM membership_application
|
||||||
|
WHERE application_id NOT IN (
|
||||||
|
SELECT MAX(application_id)
|
||||||
|
FROM (
|
||||||
|
SELECT application_id
|
||||||
|
FROM membership_application
|
||||||
|
) tmp
|
||||||
|
GROUP BY user_id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Add UNIQUE constraint on user_id in membership_application
|
||||||
|
ALTER TABLE membership_application
|
||||||
|
ADD CONSTRAINT uk_membership_application_user_id UNIQUE (user_id);
|
||||||
|
|
||||||
|
-- Add UNIQUE constraint to membership_fees table
|
||||||
|
-- First, delete any duplicate fees keeping the most recent one
|
||||||
|
DELETE FROM membership_fees
|
||||||
|
WHERE fee_id NOT IN (
|
||||||
|
SELECT MAX(fee_id)
|
||||||
|
FROM (
|
||||||
|
SELECT fee_id
|
||||||
|
FROM membership_fees
|
||||||
|
) tmp
|
||||||
|
GROUP BY user_id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Add UNIQUE constraint on user_id in membership_fees
|
||||||
|
ALTER TABLE membership_fees
|
||||||
|
ADD CONSTRAINT uk_membership_fees_user_id UNIQUE (user_id);
|
||||||
|
|
||||||
@@ -1434,6 +1434,10 @@ function checkMembershipApplication($user_id)
|
|||||||
|
|
||||||
// Check if the record exists and redirect
|
// Check if the record exists and redirect
|
||||||
if ($count > 0) {
|
if ($count > 0) {
|
||||||
|
// Set a session message before redirecting
|
||||||
|
if (!isset($_SESSION['message'])) {
|
||||||
|
$_SESSION['message'] = 'You have already submitted a membership application.';
|
||||||
|
}
|
||||||
header("Location: membership_details.php");
|
header("Location: membership_details.php");
|
||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,40 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
die('Security token validation failed. Please try again.');
|
die('Security token validation failed. Please try again.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if user already has a membership application
|
||||||
|
$check_stmt = $conn->prepare("SELECT COUNT(*) as count FROM membership_application WHERE user_id = ?");
|
||||||
|
$check_stmt->bind_param("i", $user_id);
|
||||||
|
$check_stmt->execute();
|
||||||
|
$check_result = $check_stmt->get_result();
|
||||||
|
$check_row = $check_result->fetch_assoc();
|
||||||
|
$check_stmt->close();
|
||||||
|
|
||||||
|
if ($check_row['count'] > 0) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'You have already submitted a membership application. Please check your email for membership details.'
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user already has a membership fee record
|
||||||
|
$fee_check_stmt = $conn->prepare("SELECT COUNT(*) as count FROM membership_fees WHERE user_id = ?");
|
||||||
|
$fee_check_stmt->bind_param("i", $user_id);
|
||||||
|
$fee_check_stmt->execute();
|
||||||
|
$fee_result = $fee_check_stmt->get_result();
|
||||||
|
$fee_row = $fee_result->fetch_assoc();
|
||||||
|
$fee_check_stmt->close();
|
||||||
|
|
||||||
|
if ($fee_row['count'] > 0) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'You already have a membership fee record. Please contact support if you need to update your application.'
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// Get all the form fields with validation
|
// Get all the form fields with validation
|
||||||
$first_name = validateName($_POST['first_name'] ?? '');
|
$first_name = validateName($_POST['first_name'] ?? '');
|
||||||
if ($first_name === false) {
|
if ($first_name === false) {
|
||||||
@@ -188,11 +222,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
// Rollback the transaction in case of error
|
// Rollback the transaction in case of error
|
||||||
$conn->rollback();
|
$conn->rollback();
|
||||||
|
|
||||||
// Error response
|
// Check for duplicate key error
|
||||||
$response = [
|
$errorMessage = $e->getMessage();
|
||||||
'status' => 'error',
|
if (strpos($errorMessage, 'Duplicate') !== false || strpos($errorMessage, '1062') !== false) {
|
||||||
'message' => 'Error: ' . $e->getMessage()
|
$response = [
|
||||||
];
|
'status' => 'error',
|
||||||
|
'message' => 'You have already submitted a membership application. Please check your email for membership details.'
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// Error response
|
||||||
|
$response = [
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Error: ' . $errorMessage
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the response in JSON format
|
// Return the response in JSON format
|
||||||
|
|||||||
Reference in New Issue
Block a user