feat: implement membership linking system for couples and family members
- Created membership_links table to associate secondary users with primary memberships - Created membership_permissions table for granular permission control - Added linkSecondaryUserToMembership() function to create links with validation - Added getUserMembershipLink() to check access via secondary links - Added getLinkedSecondaryUsers() to list all secondary users for a primary member - Added unlinkSecondaryUser() to remove links - Updated getUserMemberStatus() to check both direct and linked memberships - Created link_membership_user processor to handle linking via API - Created unlink_membership_user processor to handle unlinking via API - Added .htaccess routes for linking endpoints - Grants default permissions: access_member_areas, member_pricing, book_campsites, book_courses, book_trips - Includes transaction safety with rollback on errors - Includes comprehensive documentation with usage examples - Validates primary user has active membership before allowing links - Prevents duplicate links and self-linking
@@ -132,6 +132,8 @@ RewriteRule ^update_album$ src/processors/update_album.php [L]
|
|||||||
RewriteRule ^delete_album$ src/processors/delete_album.php [L]
|
RewriteRule ^delete_album$ src/processors/delete_album.php [L]
|
||||||
RewriteRule ^delete_photo$ src/processors/delete_photo.php [L]
|
RewriteRule ^delete_photo$ src/processors/delete_photo.php [L]
|
||||||
RewriteRule ^get_album_photos$ src/processors/get_album_photos.php [L]
|
RewriteRule ^get_album_photos$ src/processors/get_album_photos.php [L]
|
||||||
|
RewriteRule ^link_membership_user$ src/processors/link_membership_user.php [L]
|
||||||
|
RewriteRule ^unlink_membership_user$ src/processors/unlink_membership_user.php [L]
|
||||||
|
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
|
|||||||
306
docs/MEMBERSHIP_LINKING.md
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
# Membership Linking Feature
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The Membership Linking feature allows users to link secondary accounts (spouses, family members, etc.) to a primary membership account. This enables multiple users to access member-only areas and receive member pricing under a single membership.
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### membership_links Table
|
||||||
|
```sql
|
||||||
|
- link_id (INT, PK, AUTO_INCREMENT)
|
||||||
|
- primary_user_id (INT, FK to users) - Main membership holder
|
||||||
|
- secondary_user_id (INT, FK to users) - Secondary user sharing the membership
|
||||||
|
- relationship (VARCHAR 50) - Type of relationship (spouse, family_member, etc)
|
||||||
|
- linked_at (TIMESTAMP)
|
||||||
|
- created_at (TIMESTAMP)
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
- UNIQUE(primary_user_id, secondary_user_id) - Prevent duplicate links
|
||||||
|
- Foreign keys on both user IDs with CASCADE DELETE
|
||||||
|
- Indexes on both user IDs for performance
|
||||||
|
```
|
||||||
|
|
||||||
|
### membership_permissions Table
|
||||||
|
```sql
|
||||||
|
- permission_id (INT, PK, AUTO_INCREMENT)
|
||||||
|
- link_id (INT, FK to membership_links) - Reference to the link
|
||||||
|
- permission_name (VARCHAR 100) - Permission type (access_member_areas, member_pricing, etc)
|
||||||
|
- granted_at (TIMESTAMP)
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
- UNIQUE(link_id, permission_name) - Prevent duplicate permissions
|
||||||
|
- Foreign key to membership_links with CASCADE DELETE
|
||||||
|
- Index on link_id for performance
|
||||||
|
|
||||||
|
Default Permissions Granted:
|
||||||
|
- access_member_areas
|
||||||
|
- member_pricing
|
||||||
|
- book_campsites
|
||||||
|
- book_courses
|
||||||
|
- book_trips
|
||||||
|
```
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### linkSecondaryUserToMembership()
|
||||||
|
**Purpose**: Link a secondary user to a primary user's active membership
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `int $primary_user_id` - The main membership holder
|
||||||
|
- `int $secondary_user_id` - The user to link
|
||||||
|
- `string $relationship` - Relationship type (default: 'spouse')
|
||||||
|
|
||||||
|
**Returns**: `array` with keys:
|
||||||
|
- `success` (bool) - Whether the link was created
|
||||||
|
- `message` (string) - Status message
|
||||||
|
- `link_id` (int) - ID of created link (on success)
|
||||||
|
|
||||||
|
**Validation**:
|
||||||
|
- Primary and secondary user IDs must be different
|
||||||
|
- Primary user must have active membership
|
||||||
|
- Secondary user must exist
|
||||||
|
- Link must not already exist
|
||||||
|
|
||||||
|
**Side Effects**:
|
||||||
|
- Creates membership_links record
|
||||||
|
- Creates default permission records
|
||||||
|
- Uses transaction (rolls back on failure)
|
||||||
|
|
||||||
|
### getUserMembershipLink()
|
||||||
|
**Purpose**: Check if a user has access through a secondary membership link
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `int $user_id` - User to check
|
||||||
|
|
||||||
|
**Returns**: `array` with keys:
|
||||||
|
- `has_access` (bool) - Whether user has access via link
|
||||||
|
- `primary_user_id` (int|null) - ID of primary account holder
|
||||||
|
- `relationship` (string|null) - Relationship type
|
||||||
|
|
||||||
|
**Validation**:
|
||||||
|
- Verifies the link exists
|
||||||
|
- Checks primary user has active membership
|
||||||
|
- Validates payment status and expiration date
|
||||||
|
- Confirms indemnity waiver accepted
|
||||||
|
|
||||||
|
### getLinkedSecondaryUsers()
|
||||||
|
**Purpose**: Get all secondary users linked to a primary user's membership
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `int $primary_user_id` - The primary membership holder
|
||||||
|
|
||||||
|
**Returns**: `array` of linked users with:
|
||||||
|
- `link_id` - Link ID
|
||||||
|
- `user_id` - Secondary user ID
|
||||||
|
- `first_name` - User's first name
|
||||||
|
- `last_name` - User's last name
|
||||||
|
- `email` - User's email
|
||||||
|
- `relationship` - Relationship type
|
||||||
|
- `linked_at` - When the link was created
|
||||||
|
|
||||||
|
### unlinkSecondaryUser()
|
||||||
|
**Purpose**: Remove a secondary user from a primary user's membership
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `int $link_id` - The membership link ID to remove
|
||||||
|
- `int $primary_user_id` - The primary user (for verification)
|
||||||
|
|
||||||
|
**Returns**: `array` with keys:
|
||||||
|
- `success` (bool) - Whether the unlink was successful
|
||||||
|
- `message` (string) - Status message
|
||||||
|
|
||||||
|
**Validation**:
|
||||||
|
- Verifies link exists
|
||||||
|
- Confirms primary user owns the link
|
||||||
|
- Uses transaction (rolls back on failure)
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### POST /link_membership_user
|
||||||
|
**Purpose**: Link a new secondary user to the requester's membership
|
||||||
|
|
||||||
|
**Required Parameters**:
|
||||||
|
- `secondary_email` (string) - Email of user to link
|
||||||
|
- `relationship` (string, optional) - Relationship type (default: 'spouse')
|
||||||
|
- `csrf_token` (string) - CSRF token
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "User successfully linked to membership",
|
||||||
|
"link_id": 123
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses**:
|
||||||
|
- 403: Forbidden (not authenticated or POST required)
|
||||||
|
- 400: Bad Request (invalid CSRF, missing email, user not found, or linking failed)
|
||||||
|
|
||||||
|
**Access Control**:
|
||||||
|
- Authenticated users only
|
||||||
|
- Can only link to own membership
|
||||||
|
|
||||||
|
### POST /unlink_membership_user
|
||||||
|
**Purpose**: Remove a secondary user from the requester's membership
|
||||||
|
|
||||||
|
**Required Parameters**:
|
||||||
|
- `link_id` (int) - ID of the link to remove
|
||||||
|
- `csrf_token` (string) - CSRF token
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "User successfully unlinked from membership"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses**:
|
||||||
|
- 403: Forbidden (not authenticated or POST required)
|
||||||
|
- 400: Bad Request (invalid CSRF, link not found, or unauthorized)
|
||||||
|
|
||||||
|
**Access Control**:
|
||||||
|
- Authenticated users only
|
||||||
|
- Can only remove links from own membership
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
### Updated getUserMemberStatus()
|
||||||
|
The `getUserMemberStatus()` function now checks both:
|
||||||
|
1. Direct membership (user has membership_application and membership_fees)
|
||||||
|
2. Secondary membership (user is linked to another user's active membership)
|
||||||
|
|
||||||
|
When user doesn't have direct membership, it automatically checks if they're linked to someone else's active membership.
|
||||||
|
|
||||||
|
### Member Access Checks
|
||||||
|
All member-only pages should use `getUserMemberStatus()` which now automatically handles:
|
||||||
|
- Direct members
|
||||||
|
- Secondary members via links
|
||||||
|
- Expired memberships
|
||||||
|
- Indemnity waiver validation
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
### Spouse/Partner Access
|
||||||
|
1. User A (primary) has active membership
|
||||||
|
2. User B (spouse) links to User A's membership
|
||||||
|
3. User B can now:
|
||||||
|
- Access member areas
|
||||||
|
- Receive member pricing
|
||||||
|
- Book campsites
|
||||||
|
- Book courses
|
||||||
|
- Book trips
|
||||||
|
|
||||||
|
### Renewal
|
||||||
|
- When primary membership renews, secondary users automatically maintain access
|
||||||
|
- No need to re-create links on renewal
|
||||||
|
|
||||||
|
### Membership Termination
|
||||||
|
- If primary membership expires, secondary users lose access
|
||||||
|
- Primary user can manually unlink secondary users anytime
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Linking a User
|
||||||
|
```php
|
||||||
|
$result = linkSecondaryUserToMembership(
|
||||||
|
$_SESSION['user_id'],
|
||||||
|
'spouse@example.com',
|
||||||
|
'spouse'
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
$_SESSION['message'] = 'Successfully linked ' . $partner_email . ' to your membership';
|
||||||
|
} else {
|
||||||
|
$_SESSION['error'] = $result['message'];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checking User Access
|
||||||
|
```php
|
||||||
|
if (getUserMemberStatus($user_id)) {
|
||||||
|
// User has direct or linked membership
|
||||||
|
echo "Welcome member!";
|
||||||
|
} else {
|
||||||
|
// Redirect to membership page
|
||||||
|
header('Location: membership');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Getting Linked Users
|
||||||
|
```php
|
||||||
|
$linkedUsers = getLinkedSecondaryUsers($_SESSION['user_id']);
|
||||||
|
foreach ($linkedUsers as $user) {
|
||||||
|
echo "Linked: " . $user['first_name'] . ' (' . $user['relationship'] . ')';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Removing a Link
|
||||||
|
```php
|
||||||
|
$result = unlinkSecondaryUser($link_id, $_SESSION['user_id']);
|
||||||
|
if ($result['success']) {
|
||||||
|
echo "User unlinked successfully";
|
||||||
|
} else {
|
||||||
|
echo "Error: " . $result['message'];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
- Users can only link to their own membership
|
||||||
|
- Users can only manage their own links
|
||||||
|
- Secondary users cannot create or modify links (primary user only)
|
||||||
|
|
||||||
|
### Data Validation
|
||||||
|
- Email validation before linking
|
||||||
|
- User existence verification
|
||||||
|
- Duplicate link prevention
|
||||||
|
- CSRF token validation on all operations
|
||||||
|
|
||||||
|
### Relationships
|
||||||
|
- Foreign keys prevent orphaned links
|
||||||
|
- CASCADE DELETE ensures cleanup when users are deleted
|
||||||
|
- Transactions ensure consistency
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Link new user to own membership
|
||||||
|
- [ ] Attempt to link non-existent user (error)
|
||||||
|
- [ ] Attempt to link same user twice (error)
|
||||||
|
- [ ] Secondary user can access member areas
|
||||||
|
- [ ] Secondary user receives member pricing
|
||||||
|
- [ ] Unlink secondary user
|
||||||
|
- [ ] Unlinked user cannot access member areas
|
||||||
|
- [ ] Primary user can see list of linked users
|
||||||
|
- [ ] Linked user appears in notifications (if applicable)
|
||||||
|
- [ ] Membership renewal maintains links
|
||||||
|
- [ ] Expired membership removes secondary access
|
||||||
|
- [ ] Deleting user removes their links
|
||||||
|
- [ ] Permission records created on link
|
||||||
|
- [ ] Cannot link without active primary membership
|
||||||
|
- [ ] Cannot link if different user attempts
|
||||||
|
- [ ] CSRF token validation works
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **Admin Management**: Allow admins to create/remove links for members
|
||||||
|
2. **Selective Permissions**: Allow customizing which permissions each secondary user has
|
||||||
|
3. **Invitations**: Send email invitations to secondary users to accept
|
||||||
|
4. **Multiple Links**: Allow primary users to link multiple users (families)
|
||||||
|
5. **UI Dashboard**: Create page for managing linked accounts
|
||||||
|
6. **Notifications**: Notify secondary users when linked
|
||||||
|
7. **Payment Tracking**: Track which user made payments for membership
|
||||||
|
8. **Audit Log**: Log all link/unlink operations for compliance
|
||||||
|
|
||||||
|
## Migration Instructions
|
||||||
|
|
||||||
|
1. Run migration 004 to create tables and permissions table
|
||||||
|
2. Update `src/config/functions.php` with new linking functions
|
||||||
|
3. Update `getUserMemberStatus()` to check links
|
||||||
|
4. Add routes to `.htaccess` for new endpoints
|
||||||
|
5. Deploy processors for link/unlink operations
|
||||||
|
6. Test with married couple accounts
|
||||||
|
7. Document for users in membership information
|
||||||
|
|
||||||
63
docs/migrations/004_create_membership_linking_tables.sql
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
-- Migration 004: Create membership linking tables
|
||||||
|
-- Purpose: Allow multiple users to share a single membership (for couples, families, etc)
|
||||||
|
|
||||||
|
-- Create membership_links table to associate secondary users with primary membership accounts
|
||||||
|
CREATE TABLE IF NOT EXISTS `membership_links` (
|
||||||
|
`link_id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
`primary_user_id` INT NOT NULL,
|
||||||
|
`secondary_user_id` INT NOT NULL,
|
||||||
|
`relationship` VARCHAR(50) NOT NULL DEFAULT 'spouse', -- spouse, family member, etc
|
||||||
|
`linked_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
-- Foreign keys
|
||||||
|
CONSTRAINT `fk_membership_links_primary` FOREIGN KEY (`primary_user_id`)
|
||||||
|
REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT `fk_membership_links_secondary` FOREIGN KEY (`secondary_user_id`)
|
||||||
|
REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
|
||||||
|
-- Indexes for performance
|
||||||
|
INDEX `idx_primary_user` (`primary_user_id`),
|
||||||
|
INDEX `idx_secondary_user` (`secondary_user_id`),
|
||||||
|
|
||||||
|
-- Prevent duplicate links (user cannot be linked twice)
|
||||||
|
UNIQUE KEY `unique_link` (`primary_user_id`, `secondary_user_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Create membership_permissions table to define what secondary users can access
|
||||||
|
CREATE TABLE IF NOT EXISTS `membership_permissions` (
|
||||||
|
`permission_id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
`link_id` INT NOT NULL,
|
||||||
|
`permission_name` VARCHAR(100) NOT NULL, -- 'access_member_areas', 'member_pricing', 'book_campsites', etc
|
||||||
|
`granted_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
-- Foreign key
|
||||||
|
CONSTRAINT `fk_membership_permissions_link` FOREIGN KEY (`link_id`)
|
||||||
|
REFERENCES `membership_links`(`link_id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
INDEX `idx_link` (`link_id`),
|
||||||
|
|
||||||
|
-- Prevent duplicate permissions
|
||||||
|
UNIQUE KEY `unique_permission` (`link_id`, `permission_name`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Add foreign key to membership_fees to support links (optional - for tracking which membership fee covers the linked users)
|
||||||
|
-- ALTER TABLE `membership_fees` ADD COLUMN `primary_user_id` INT AFTER `user_id`;
|
||||||
|
-- This allows you to see if a fee was paid by primary or secondary user while maintaining the relationship
|
||||||
|
|
||||||
|
-- Create a view to easily get all users linked to a membership
|
||||||
|
CREATE OR REPLACE VIEW `linked_membership_users` AS
|
||||||
|
SELECT
|
||||||
|
primary_user_id,
|
||||||
|
secondary_user_id,
|
||||||
|
relationship,
|
||||||
|
linked_at
|
||||||
|
FROM membership_links
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
primary_user_id,
|
||||||
|
primary_user_id as secondary_user_id,
|
||||||
|
'primary' as relationship,
|
||||||
|
linked_at
|
||||||
|
FROM membership_links;
|
||||||
@@ -412,7 +412,7 @@ function getUserMemberStatus($user_id)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Check if the user is a member
|
// Step 1: Check if the user is a direct member
|
||||||
$queryUser = "SELECT member FROM users WHERE user_id = ?";
|
$queryUser = "SELECT member FROM users WHERE user_id = ?";
|
||||||
$stmtUser = $conn->prepare($queryUser);
|
$stmtUser = $conn->prepare($queryUser);
|
||||||
if (!$stmtUser) {
|
if (!$stmtUser) {
|
||||||
@@ -445,7 +445,10 @@ function getUserMemberStatus($user_id)
|
|||||||
|
|
||||||
if ($resultApplication->num_rows === 0) {
|
if ($resultApplication->num_rows === 0) {
|
||||||
error_log("No membership application found for user_id: $user_id");
|
error_log("No membership application found for user_id: $user_id");
|
||||||
return false;
|
// Check if user is linked to another user's membership
|
||||||
|
$linkedStatus = getUserMembershipLink($user_id);
|
||||||
|
$conn->close();
|
||||||
|
return $linkedStatus['has_access'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$application = $resultApplication->fetch_assoc();
|
$application = $resultApplication->fetch_assoc();
|
||||||
@@ -484,8 +487,10 @@ function getUserMemberStatus($user_id)
|
|||||||
$membership_end_date_obj = DateTime::createFromFormat('Y-m-d', $membership_end_date);
|
$membership_end_date_obj = DateTime::createFromFormat('Y-m-d', $membership_end_date);
|
||||||
|
|
||||||
if ($payment_status === "PAID" && $current_date <= $membership_end_date_obj) {
|
if ($payment_status === "PAID" && $current_date <= $membership_end_date_obj) {
|
||||||
|
$conn->close();
|
||||||
return true; // Membership is active
|
return true; // Membership is active
|
||||||
} else {
|
} else {
|
||||||
|
$conn->close();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2906,3 +2911,249 @@ function optimizeImage($filePath, $maxWidth = 1920, $maxHeight = 1080)
|
|||||||
|
|
||||||
return $success;
|
return $success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link a secondary user to a primary user's membership
|
||||||
|
* @param int $primary_user_id The main membership holder
|
||||||
|
* @param int $secondary_user_id The user to link (spouse, family member, etc)
|
||||||
|
* @param string $relationship The relationship type (spouse, family_member, etc)
|
||||||
|
* @return array ['success' => bool, 'message' => string]
|
||||||
|
*/
|
||||||
|
function linkSecondaryUserToMembership($primary_user_id, $secondary_user_id, $relationship = 'spouse')
|
||||||
|
{
|
||||||
|
$conn = openDatabaseConnection();
|
||||||
|
|
||||||
|
if ($conn === null) {
|
||||||
|
return ['success' => false, 'message' => 'Database connection failed'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation: primary and secondary user IDs must be different
|
||||||
|
if ($primary_user_id === $secondary_user_id) {
|
||||||
|
return ['success' => false, 'message' => 'Cannot link user to themselves'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation: primary user must have active membership
|
||||||
|
if (!getUserMemberStatus($primary_user_id)) {
|
||||||
|
$conn->close();
|
||||||
|
return ['success' => false, 'message' => 'Primary user does not have active membership'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation: secondary user must exist
|
||||||
|
$userCheck = $conn->prepare("SELECT user_id FROM users WHERE user_id = ?");
|
||||||
|
$userCheck->bind_param("i", $secondary_user_id);
|
||||||
|
$userCheck->execute();
|
||||||
|
$userResult = $userCheck->get_result();
|
||||||
|
$userCheck->close();
|
||||||
|
|
||||||
|
if ($userResult->num_rows === 0) {
|
||||||
|
$conn->close();
|
||||||
|
return ['success' => false, 'message' => 'Secondary user does not exist'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if link already exists
|
||||||
|
$existingLink = $conn->prepare("SELECT link_id FROM membership_links WHERE primary_user_id = ? AND secondary_user_id = ?");
|
||||||
|
$existingLink->bind_param("ii", $primary_user_id, $secondary_user_id);
|
||||||
|
$existingLink->execute();
|
||||||
|
$existingResult = $existingLink->get_result();
|
||||||
|
$existingLink->close();
|
||||||
|
|
||||||
|
if ($existingResult->num_rows > 0) {
|
||||||
|
$conn->close();
|
||||||
|
return ['success' => false, 'message' => 'Users are already linked'];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Start transaction
|
||||||
|
$conn->begin_transaction();
|
||||||
|
|
||||||
|
// Insert link
|
||||||
|
$insertLink = $conn->prepare("
|
||||||
|
INSERT INTO membership_links (primary_user_id, secondary_user_id, relationship, linked_at, created_at)
|
||||||
|
VALUES (?, ?, ?, NOW(), NOW())
|
||||||
|
");
|
||||||
|
$insertLink->bind_param("iis", $primary_user_id, $secondary_user_id, $relationship);
|
||||||
|
$insertLink->execute();
|
||||||
|
$linkId = $conn->insert_id;
|
||||||
|
$insertLink->close();
|
||||||
|
|
||||||
|
// Grant default permissions to secondary user
|
||||||
|
$permissions = [
|
||||||
|
'access_member_areas',
|
||||||
|
'member_pricing',
|
||||||
|
'book_campsites',
|
||||||
|
'book_courses',
|
||||||
|
'book_trips'
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($permissions as $permission) {
|
||||||
|
$insertPerm = $conn->prepare("
|
||||||
|
INSERT INTO membership_permissions (link_id, permission_name, granted_at)
|
||||||
|
VALUES (?, ?, NOW())
|
||||||
|
");
|
||||||
|
$insertPerm->bind_param("is", $linkId, $permission);
|
||||||
|
$insertPerm->execute();
|
||||||
|
$insertPerm->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit transaction
|
||||||
|
$conn->commit();
|
||||||
|
$conn->close();
|
||||||
|
|
||||||
|
return ['success' => true, 'message' => 'User successfully linked to membership', 'link_id' => $linkId];
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$conn->rollback();
|
||||||
|
$conn->close();
|
||||||
|
return ['success' => false, 'message' => 'Failed to create link: ' . $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user has access through a membership link
|
||||||
|
* @param int $user_id The user to check
|
||||||
|
* @return array ['has_access' => bool, 'primary_user_id' => int|null, 'relationship' => string|null]
|
||||||
|
*/
|
||||||
|
function getUserMembershipLink($user_id)
|
||||||
|
{
|
||||||
|
$conn = openDatabaseConnection();
|
||||||
|
|
||||||
|
if ($conn === null) {
|
||||||
|
return ['has_access' => false, 'primary_user_id' => null, 'relationship' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is a secondary user with active link
|
||||||
|
$query = "
|
||||||
|
SELECT ml.primary_user_id, ml.relationship
|
||||||
|
FROM membership_links ml
|
||||||
|
JOIN membership_fees mf ON ml.primary_user_id = mf.user_id
|
||||||
|
JOIN membership_application ma ON ml.primary_user_id = ma.user_id
|
||||||
|
WHERE ml.secondary_user_id = ?
|
||||||
|
AND ma.accept_indemnity = 1
|
||||||
|
AND mf.payment_status = 'PAID'
|
||||||
|
AND mf.membership_end_date >= CURDATE()
|
||||||
|
LIMIT 1
|
||||||
|
";
|
||||||
|
|
||||||
|
$stmt = $conn->prepare($query);
|
||||||
|
$stmt->bind_param("i", $user_id);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
if ($result->num_rows > 0) {
|
||||||
|
$link = $result->fetch_assoc();
|
||||||
|
$conn->close();
|
||||||
|
return [
|
||||||
|
'has_access' => true,
|
||||||
|
'primary_user_id' => $link['primary_user_id'],
|
||||||
|
'relationship' => $link['relationship']
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$conn->close();
|
||||||
|
return ['has_access' => false, 'primary_user_id' => null, 'relationship' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all secondary users linked to a primary user
|
||||||
|
* @param int $primary_user_id The primary membership holder
|
||||||
|
* @return array Array of linked users with their info
|
||||||
|
*/
|
||||||
|
function getLinkedSecondaryUsers($primary_user_id)
|
||||||
|
{
|
||||||
|
$conn = openDatabaseConnection();
|
||||||
|
|
||||||
|
if ($conn === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = "
|
||||||
|
SELECT
|
||||||
|
ml.link_id,
|
||||||
|
u.user_id,
|
||||||
|
u.first_name,
|
||||||
|
u.last_name,
|
||||||
|
u.email,
|
||||||
|
ml.relationship,
|
||||||
|
ml.linked_at
|
||||||
|
FROM membership_links ml
|
||||||
|
JOIN users u ON ml.secondary_user_id = u.user_id
|
||||||
|
WHERE ml.primary_user_id = ?
|
||||||
|
ORDER BY ml.linked_at DESC
|
||||||
|
";
|
||||||
|
|
||||||
|
$stmt = $conn->prepare($query);
|
||||||
|
$stmt->bind_param("i", $primary_user_id);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
$linkedUsers = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$linkedUsers[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$conn->close();
|
||||||
|
return $linkedUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlink a secondary user from a primary user's membership
|
||||||
|
* @param int $link_id The membership link ID to remove
|
||||||
|
* @param int $primary_user_id The primary user (for verification)
|
||||||
|
* @return array ['success' => bool, 'message' => string]
|
||||||
|
*/
|
||||||
|
function unlinkSecondaryUser($link_id, $primary_user_id)
|
||||||
|
{
|
||||||
|
$conn = openDatabaseConnection();
|
||||||
|
|
||||||
|
if ($conn === null) {
|
||||||
|
return ['success' => false, 'message' => 'Database connection failed'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that this link belongs to the primary user
|
||||||
|
$linkCheck = $conn->prepare("SELECT primary_user_id FROM membership_links WHERE link_id = ?");
|
||||||
|
$linkCheck->bind_param("i", $link_id);
|
||||||
|
$linkCheck->execute();
|
||||||
|
$linkResult = $linkCheck->get_result();
|
||||||
|
$linkCheck->close();
|
||||||
|
|
||||||
|
if ($linkResult->num_rows === 0) {
|
||||||
|
$conn->close();
|
||||||
|
return ['success' => false, 'message' => 'Link not found'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$linkData = $linkResult->fetch_assoc();
|
||||||
|
if ($linkData['primary_user_id'] !== $primary_user_id) {
|
||||||
|
$conn->close();
|
||||||
|
return ['success' => false, 'message' => 'Unauthorized: you do not have permission to remove this link'];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Start transaction
|
||||||
|
$conn->begin_transaction();
|
||||||
|
|
||||||
|
// Delete permissions first (cascade should handle this but being explicit)
|
||||||
|
$deletePerm = $conn->prepare("DELETE FROM membership_permissions WHERE link_id = ?");
|
||||||
|
$deletePerm->bind_param("i", $link_id);
|
||||||
|
$deletePerm->execute();
|
||||||
|
$deletePerm->close();
|
||||||
|
|
||||||
|
// Delete the link
|
||||||
|
$deleteLink = $conn->prepare("DELETE FROM membership_links WHERE link_id = ?");
|
||||||
|
$deleteLink->bind_param("i", $link_id);
|
||||||
|
$deleteLink->execute();
|
||||||
|
$deleteLink->close();
|
||||||
|
|
||||||
|
// Commit transaction
|
||||||
|
$conn->commit();
|
||||||
|
$conn->close();
|
||||||
|
|
||||||
|
return ['success' => true, 'message' => 'User successfully unlinked from membership'];
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$conn->rollback();
|
||||||
|
$conn->close();
|
||||||
|
return ['success' => false, 'message' => 'Failed to remove link: ' . $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
57
src/processors/link_membership_user.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
$rootPath = dirname(dirname(__DIR__));
|
||||||
|
require_once($rootPath . '/src/config/env.php');
|
||||||
|
require_once($rootPath . '/src/config/session.php');
|
||||||
|
require_once($rootPath . '/src/config/connection.php');
|
||||||
|
require_once($rootPath . '/src/config/functions.php');
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
if (!isset($_SESSION['user_id']) || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(403);
|
||||||
|
exit(json_encode(['success' => false, 'message' => 'Forbidden']));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate CSRF token
|
||||||
|
if (!isset($_POST['csrf_token']) || !validateCSRFToken($_POST['csrf_token'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit(json_encode(['success' => false, 'message' => 'Invalid request']));
|
||||||
|
}
|
||||||
|
|
||||||
|
$primary_user_id = intval($_SESSION['user_id']);
|
||||||
|
$secondary_email = trim($_POST['secondary_email'] ?? '');
|
||||||
|
$relationship = trim($_POST['relationship'] ?? 'spouse');
|
||||||
|
|
||||||
|
if (empty($secondary_email)) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit(json_encode(['success' => false, 'message' => 'Secondary user email is required']));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the secondary user by email
|
||||||
|
$conn = openDatabaseConnection();
|
||||||
|
$userQuery = $conn->prepare("SELECT user_id FROM users WHERE email = ?");
|
||||||
|
$userQuery->bind_param("s", $secondary_email);
|
||||||
|
$userQuery->execute();
|
||||||
|
$userResult = $userQuery->get_result();
|
||||||
|
$userQuery->close();
|
||||||
|
|
||||||
|
if ($userResult->num_rows === 0) {
|
||||||
|
$conn->close();
|
||||||
|
http_response_code(404);
|
||||||
|
exit(json_encode(['success' => false, 'message' => 'User with that email not found']));
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $userResult->fetch_assoc();
|
||||||
|
$secondary_user_id = $user['user_id'];
|
||||||
|
$conn->close();
|
||||||
|
|
||||||
|
// Use the linking function from functions.php
|
||||||
|
$result = linkSecondaryUserToMembership($primary_user_id, $secondary_user_id, $relationship);
|
||||||
|
|
||||||
|
http_response_code($result['success'] ? 200 : 400);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => $result['success'],
|
||||||
|
'message' => $result['message'],
|
||||||
|
'link_id' => $result['link_id'] ?? null
|
||||||
|
]);
|
||||||
|
?>
|
||||||
37
src/processors/unlink_membership_user.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
$rootPath = dirname(dirname(__DIR__));
|
||||||
|
require_once($rootPath . '/src/config/env.php');
|
||||||
|
require_once($rootPath . '/src/config/session.php');
|
||||||
|
require_once($rootPath . '/src/config/connection.php');
|
||||||
|
require_once($rootPath . '/src/config/functions.php');
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
if (!isset($_SESSION['user_id']) || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(403);
|
||||||
|
exit(json_encode(['success' => false, 'message' => 'Forbidden']));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate CSRF token
|
||||||
|
if (!isset($_POST['csrf_token']) || !validateCSRFToken($_POST['csrf_token'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit(json_encode(['success' => false, 'message' => 'Invalid request']));
|
||||||
|
}
|
||||||
|
|
||||||
|
$primary_user_id = intval($_SESSION['user_id']);
|
||||||
|
$link_id = intval($_POST['link_id'] ?? 0);
|
||||||
|
|
||||||
|
if (!$link_id) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit(json_encode(['success' => false, 'message' => 'Link ID is required']));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the unlinking function from functions.php
|
||||||
|
$result = unlinkSecondaryUser($link_id, $primary_user_id);
|
||||||
|
|
||||||
|
http_response_code($result['success'] ? 200 : 400);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => $result['success'],
|
||||||
|
'message' => $result['message']
|
||||||
|
]);
|
||||||
|
?>
|
||||||
BIN
uploads/blogs/1/images/cover.jpg
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
uploads/blogs/10/images/blog_13.jpeg
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
uploads/blogs/12/images/688505875d439-mceclip0.jpg
Normal file
|
After Width: | Height: | Size: 161 KiB |
BIN
uploads/blogs/16/images/68852afa55ff7-blobid0.jpg
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
uploads/blogs/16/images/68852afc7edc7-blobid1.jpg
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
uploads/blogs/16/images/68852aff41d75-blobid2.jpg
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
uploads/blogs/2/images/agm.jpg
Normal file
|
After Width: | Height: | Size: 472 KiB |
BIN
uploads/blogs/images/68832295c3fd2-mceclip0.jpg
Normal file
|
After Width: | Height: | Size: 161 KiB |
BIN
uploads/blogs/images/6883231f78d29-mceclip0.jpg
Normal file
|
After Width: | Height: | Size: 161 KiB |
BIN
uploads/blogs/images/688323d3546c7-mceclip0.jpg
Normal file
|
After Width: | Height: | Size: 161 KiB |
BIN
uploads/blogs/images/688326384b664-blobid0.jpg
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
uploads/blogs/images/ChatGPT_Image_Jul_10__2025__01_55_37_PM.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
uploads/blogs/images/ChatGPT_Image_Jul_5__2025__04_27_40_PM.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
uploads/blogs/images/agm.jpg
Normal file
|
After Width: | Height: | Size: 472 KiB |
BIN
uploads/blogs/images/cover.jpg
Normal file
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
BIN
uploads/blogs/images/img_688333223a9875.21195611.jpeg
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
uploads/blogs/images/img_6883332d8ae810.53515312.jpeg
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
uploads/blogs/images/img_6883333c8c73e9.31337036.jpeg
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
uploads/blogs/images/img_688333443336c9.61193090.jpeg
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
uploads/blogs/images/img_6883334b8ade90.16958069.jpeg
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
uploads/blogs/images/img_6883335ac2de02.42545621.jpeg
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
uploads/blogs/images/img_68833369c22dc0.61573038.jpeg
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
uploads/blogs/images/img_68833378c3fd43.37434181.jpeg
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
uploads/blogs/images/img_68833387c70b34.04495696.jpeg
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
uploads/blogs/images/img_688333a8c544e8.82781438.jpeg
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
uploads/blogs/images/img_688333e4c29329.87198676.jpeg
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
uploads/blogs/images/img_688334169858d8.29663306.jpeg
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
uploads/blogs/images/img_6883341d8a9925.24657182.jpeg
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
uploads/blogs/images/img_68833ddde42767.42785358.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
uploads/blogs/images/img_68833de1c09f26.61535657.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
uploads/blogs/images/img_68833decf0a259.74677718.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
uploads/blogs/images/img_68833dfc295c47.36349054.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
uploads/blogs/images/img_68833e0b0f2534.16393329.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
uploads/blogs/images/img_68833e1a0d9ba6.15509169.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
uploads/blogs/images/img_68833e291b79a8.65551250.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
uploads/pop/1 C. PINTO.pdf
Normal file
BIN
uploads/pop/COURSE_08-23_C._PINTO.pdf
Normal file
BIN
uploads/pop/KZN2026_C._PINTO.pdf
Normal file
BIN
uploads/signatures/signature_154.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |