Skip to content

Feature Request: Bulk Subscription Assignment #275

@mustafaneguib

Description

@mustafaneguib

GitHub Issue: Bulk Subscription Assignment

Issue Type

Feature Enhancement

Priority

Medium-High

Effort Estimation

5-8 days (Medium-Large)

Labels

enhancement, backend, frontend, admin-panel, subscription-management, bulk-operations


Problem Statement

Currently, administrators can only assign subscription tiers to users one at a time through the user edit page. This is inefficient when managing large user bases or during:

  1. Initial rollout - Assigning tiers to existing users after implementing subscription system
  2. Organizational changes - Upgrading/downgrading entire teams or departments
  3. Promotional campaigns - Bulk upgrading users for limited-time offers
  4. Testing/staging - Quickly setting up test scenarios with multiple users
  5. Contract renewals - Batch updating subscriptions at fiscal year end

Current workflow for 100 users:

  • Navigate to user edit page × 100
  • Select tier from dropdown × 100
  • Click "Update Subscription" × 100
  • Wait for confirmation × 100
  • Total time: ~30 minutes

Desired workflow:

  • Select 100 users
  • Choose tier
  • Set optional expiration
  • Bulk assign in one operation
  • Total time: ~2 minutes

Current State

Implemented

  • ✅ Single user subscription assignment via UI
  • ✅ Backend API: PUT /admin/users/:userId/subscription
  • ✅ Transaction-based subscription updates
  • ✅ Users list with subscription column

Missing

  • ❌ Multi-select functionality in users list
  • ❌ Bulk assignment UI/modal
  • ❌ Backend bulk assignment endpoint
  • ❌ Progress tracking for large batches
  • ❌ Error handling for partial failures
  • ❌ Dry-run/preview functionality

Proposed Solution

Architecture Overview

User Selection → Bulk Action Modal → Backend Processing → Progress Tracking → Results Summary
      ↓                  ↓                    ↓                    ↓                ↓
  Checkboxes      Tier Selection    Queue Service (optional)    WebSocket      Success/Errors
                  Expiration Date   Batch Processing           or Polling      Retry Failed

UI/UX Design

1. Users List Enhancement

File: frontend/pages/admin/users/index.vue

<template>
  <div class="users-list">
    <!-- Bulk Actions Bar (appears when users selected) -->
    <div v-if="state.selectedUsers.length > 0" class="bulk-actions-bar">
      <div class="selected-count">
        {{ state.selectedUsers.length }} user(s) selected
        <button @click="clearSelection" class="text-gray-500">Clear</button>
      </div>
      <div class="actions">
        <button @click="openBulkAssignModal" class="btn-primary">
          <Icon name="carbon:user-multiple" />
          Assign Subscription
        </button>
        <button @click="openBulkExportModal" class="btn-secondary">
          <Icon name="carbon:download" />
          Export Selected
        </button>
      </div>
    </div>

    <!-- Users Table -->
    <table>
      <thead>
        <tr>
          <th>
            <input type="checkbox" 
                   v-model="state.selectAll" 
                   @change="toggleSelectAll"
                   :indeterminate="isIndeterminate" />
          </th>
          <th>Name</th>
          <th>Email</th>
          <th>Subscription</th>
          <th>Actions</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="user in users" :key="user.id">
          <td>
            <input type="checkbox" 
                   v-model="state.selectedUsers" 
                   :value="user.id" />
          </td>
          <td>{{ user.name }}</td>
          <td>{{ user.email }}</td>
          <td>{{ getUserSubscription(user.id) }}</td>
          <td><!-- Actions --></td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

2. Bulk Assignment Modal

Component: frontend/components/admin/BulkSubscriptionAssignmentModal.vue

<template>
  <Dialog v-model:open="isOpen" title="Bulk Assign Subscriptions">
    <div class="space-y-6">
      <!-- Summary -->
      <div class="bg-blue-50 p-4 rounded">
        <h4 class="font-semibold">Assigning subscription to:</h4>
        <p class="text-sm">{{ userCount }} selected user(s)</p>
        <button @click="showUserList = !showUserList" class="text-blue-600 text-sm">
          {{ showUserList ? 'Hide' : 'Show' }} user list
        </button>
        <div v-if="showUserList" class="mt-2 max-h-32 overflow-y-auto">
          <div v-for="user in selectedUsers" :key="user.id" class="text-xs">
            {{ user.name }} ({{ user.email }})
          </div>
        </div>
      </div>

      <!-- Tier Selection -->
      <div>
        <label class="block text-sm font-medium mb-2">Subscription Tier</label>
        <select v-model="state.selectedTierId" class="w-full">
          <option :value="null">Select a tier</option>
          <option v-for="tier in availableTiers" :key="tier.id" :value="tier.id">
            {{ tier.tier_name }} - ${{ tier.price_per_month_usd }}/mo
          </option>
        </select>
      </div>

      <!-- Expiration Date (Optional) -->
      <div>
        <label class="block text-sm font-medium mb-2">
          Expiration Date (Optional)
        </label>
        <input type="date" 
               v-model="state.endsAt" 
               class="w-full"
               :min="minDate" />
        <p class="text-xs text-gray-500 mt-1">
          Leave empty for no expiration
        </p>
      </div>

      <!-- Conflict Handling -->
      <div>
        <label class="block text-sm font-medium mb-2">
          Users with existing subscriptions
        </label>
        <div class="space-y-2">
          <label class="flex items-center">
            <input type="radio" 
                   v-model="state.conflictStrategy" 
                   value="replace" />
            <span class="ml-2">Replace existing subscriptions (recommended)</span>
          </label>
          <label class="flex items-center">
            <input type="radio" 
                   v-model="state.conflictStrategy" 
                   value="skip" />
            <span class="ml-2">Skip users with existing subscriptions</span>
          </label>
          <label class="flex items-center">
            <input type="radio" 
                   v-model="state.conflictStrategy" 
                   value="fail" />
            <span class="ml-2">Fail if any user has existing subscription</span>
          </label>
        </div>
      </div>

      <!-- Dry Run Option -->
      <div>
        <label class="flex items-center">
          <input type="checkbox" v-model="state.dryRun" />
          <span class="ml-2 text-sm">
            Preview changes without applying (dry run)
          </span>
        </label>
      </div>

      <!-- Preview Results (Dry Run) -->
      <div v-if="state.dryRunResults" class="bg-gray-50 p-4 rounded">
        <h4 class="font-semibold mb-2">Preview Results</h4>
        <div class="text-sm space-y-1">
          <div class="text-green-600">
            ✓ {{ state.dryRunResults.willUpdate }} users will be updated
          </div>
          <div v-if="state.dryRunResults.willSkip > 0" class="text-yellow-600">
            ⚠ {{ state.dryRunResults.willSkip }} users will be skipped
          </div>
          <div v-if="state.dryRunResults.willFail > 0" class="text-red-600">
            ✗ {{ state.dryRunResults.willFail }} users will fail
          </div>
        </div>
        <button @click="runDryRun" class="btn-secondary mt-3">
          Re-run Preview
        </button>
      </div>

      <!-- Actions -->
      <div class="flex justify-end gap-3">
        <button @click="close" class="btn-secondary">Cancel</button>
        <button @click="state.dryRun ? runDryRun() : submitBulkAssignment()" 
                class="btn-primary"
                :disabled="!state.selectedTierId || state.processing">
          <Icon name="carbon:checkmark" />
          {{ state.dryRun ? 'Preview Changes' : 'Assign Subscriptions' }}
        </button>
      </div>
    </div>
  </Dialog>
</template>

3. Progress Tracking Modal

Component: frontend/components/admin/BulkOperationProgress.vue

<template>
  <Dialog v-model:open="isOpen" title="Bulk Assignment Progress" :closable="false">
    <div class="space-y-4">
      <!-- Progress Bar -->
      <div>
        <div class="flex justify-between text-sm mb-2">
          <span>Processing users...</span>
          <span>{{ state.completed }} / {{ state.total }}</span>
        </div>
        <div class="w-full bg-gray-200 rounded-full h-2">
          <div class="bg-blue-600 h-2 rounded-full transition-all" 
               :style="{ width: progressPercentage + '%' }">
          </div>
        </div>
        <div class="text-xs text-gray-500 mt-1">
          {{ progressPercentage }}% complete
        </div>
      </div>

      <!-- Live Status Updates -->
      <div class="max-h-64 overflow-y-auto space-y-1 text-sm">
        <div v-for="update in state.updates" :key="update.id" 
             :class="getUpdateClass(update)">
          <Icon :name="getUpdateIcon(update)" />
          {{ update.message }}
        </div>
      </div>

      <!-- Summary (when complete) -->
      <div v-if="state.completed === state.total" 
           class="bg-green-50 p-4 rounded">
        <h4 class="font-semibold text-green-800">Operation Complete</h4>
        <div class="text-sm mt-2 space-y-1">
          <div class="text-green-600">✓ {{ state.successful }} users updated</div>
          <div v-if="state.failed > 0" class="text-red-600">
            ✗ {{ state.failed }} users failed
          </div>
          <div v-if="state.skipped > 0" class="text-yellow-600">
            ⚠ {{ state.skipped }} users skipped
          </div>
        </div>
      </div>

      <!-- Error Details (if any) -->
      <div v-if="state.errors.length > 0" class="bg-red-50 p-4 rounded">
        <h4 class="font-semibold text-red-800 mb-2">Errors</h4>
        <div class="text-sm space-y-1 max-h-32 overflow-y-auto">
          <div v-for="error in state.errors" :key="error.userId" class="text-red-600">
            {{ error.userName }}: {{ error.message }}
          </div>
        </div>
        <button @click="retryFailed" class="btn-secondary mt-3">
          <Icon name="carbon:retry" />
          Retry Failed Operations
        </button>
      </div>

      <!-- Actions -->
      <div class="flex justify-end">
        <button @click="close" 
                class="btn-primary"
                :disabled="state.completed !== state.total">
          Close
        </button>
      </div>
    </div>
  </Dialog>
</template>

Technical Implementation

Backend Implementation

1. New Bulk Assignment Endpoint

File: backend/src/routes/admin/user-subscriptions.ts

/**
 * POST /admin/users/bulk-assign-subscriptions
 * Bulk assign subscriptions to multiple users
 */
router.post(
    '/bulk-assign-subscriptions',
    [
        body('user_ids')
            .isArray({ min: 1 })
            .withMessage('user_ids must be a non-empty array'),
        body('user_ids.*')
            .isInt({ min: 1 })
            .withMessage('Each user_id must be a positive integer'),
        body('tier_id')
            .isInt({ min: 1 })
            .withMessage('tier_id must be a positive integer'),
        body('ends_at')
            .optional()
            .isISO8601()
            .withMessage('ends_at must be a valid ISO 8601 date'),
        body('conflict_strategy')
            .optional()
            .isIn(['replace', 'skip', 'fail'])
            .withMessage('conflict_strategy must be replace, skip, or fail'),
        body('dry_run')
            .optional()
            .isBoolean()
            .withMessage('dry_run must be a boolean')
    ],
    async (req, res) => {
        const errors = validationResult(req);
        if (!errors.isEmpty()) {
            return res.status(400).json({ 
                success: false, 
                errors: errors.array() 
            });
        }
        
        try {
            const { user_ids, tier_id, ends_at, conflict_strategy, dry_run } = req.body;
            
            const endsAtDate = ends_at ? new Date(ends_at) : null;
            const strategy = conflict_strategy || 'replace';
            const isDryRun = dry_run === true;
            
            const result = await userSubscriptionProcessor.bulkAssignSubscriptions({
                userIds: user_ids,
                tierId: tier_id,
                endsAt: endsAtDate,
                conflictStrategy: strategy,
                dryRun: isDryRun
            });
            
            return res.status(200).json({
                success: true,
                data: result,
                message: isDryRun ? 'Dry run completed' : 'Bulk assignment completed'
            });
        } catch (error: any) {
            console.error('Error in bulk subscription assignment:', error);
            return res.status(500).json({
                success: false,
                message: error.message || 'Failed to bulk assign subscriptions'
            });
        }
    }
);

2. UserSubscriptionProcessor Enhancement

File: backend/src/processors/UserSubscriptionProcessor.ts

export interface IBulkAssignmentOptions {
    userIds: number[];
    tierId: number;
    endsAt: Date | null;
    conflictStrategy: 'replace' | 'skip' | 'fail';
    dryRun: boolean;
}

export interface IBulkAssignmentResult {
    total: number;
    successful: number;
    failed: number;
    skipped: number;
    errors: Array<{
        userId: number;
        userName: string;
        message: string;
    }>;
    dryRunResults?: {
        willUpdate: number;
        willSkip: number;
        willFail: number;
    };
}

/**
 * Bulk assign subscriptions to multiple users
 */
async bulkAssignSubscriptions(
    options: IBulkAssignmentOptions
): Promise<IBulkAssignmentResult> {
    const driver = await DBDriver.getInstance().getDriver(EDataSourceType.POSTGRESQL);
    if (!driver) {
        throw new Error('PostgreSQL driver not available');
    }
    
    const concreteDriver = await driver.getConcreteDriver();
    if (!concreteDriver) {
        throw new Error('Failed to get PostgreSQL connection');
    }
    
    const manager = concreteDriver.manager;
    
    // Verify tier exists and is active
    const tier = await manager.findOne(DRASubscriptionTier, { 
        where: { id: options.tierId } 
    });
    
    if (!tier || !tier.is_active) {
        throw new Error('Invalid or inactive subscription tier');
    }
    
    const result: IBulkAssignmentResult = {
        total: options.userIds.length,
        successful: 0,
        failed: 0,
        skipped: 0,
        errors: []
    };
    
    // Dry run mode: analyze what would happen without making changes
    if (options.dryRun) {
        return await this.performDryRun(options, manager, result);
    }
    
    // Process each user
    for (const userId of options.userIds) {
        try {
            // Check if user exists
            const user = await manager.findOne(DRAUsersPlatform, { 
                where: { id: userId } 
            });
            
            if (!user) {
                result.failed++;
                result.errors.push({
                    userId,
                    userName: 'Unknown',
                    message: 'User not found'
                });
                continue;
            }
            
            // Check for existing subscription
            const existingSubscription = await manager.findOne(DRAUserSubscription, {
                where: { 
                    users_platform: { id: userId },
                    is_active: true
                },
                relations: ['users_platform']
            });
            
            // Handle conflict based on strategy
            if (existingSubscription) {
                if (options.conflictStrategy === 'fail') {
                    result.failed++;
                    result.errors.push({
                        userId,
                        userName: user.name || user.email,
                        message: 'User already has active subscription'
                    });
                    continue;
                } else if (options.conflictStrategy === 'skip') {
                    result.skipped++;
                    continue;
                }
                // 'replace' strategy continues to assignment
            }
            
            // Assign subscription (using existing method)
            await this.assignSubscription(userId, options.tierId, options.endsAt);
            result.successful++;
            
        } catch (error: any) {
            result.failed++;
            result.errors.push({
                userId,
                userName: 'Unknown',
                message: error.message || 'Unknown error'
            });
        }
    }
    
    return result;
}

/**
 * Perform dry run analysis without making changes
 */
private async performDryRun(
    options: IBulkAssignmentOptions,
    manager: any,
    result: IBulkAssignmentResult
): Promise<IBulkAssignmentResult> {
    let willUpdate = 0;
    let willSkip = 0;
    let willFail = 0;
    
    for (const userId of options.userIds) {
        const user = await manager.findOne(DRAUsersPlatform, { 
            where: { id: userId } 
        });
        
        if (!user) {
            willFail++;
            continue;
        }
        
        const existingSubscription = await manager.findOne(DRAUserSubscription, {
            where: { 
                users_platform: { id: userId },
                is_active: true
            }
        });
        
        if (existingSubscription) {
            if (options.conflictStrategy === 'fail') {
                willFail++;
            } else if (options.conflictStrategy === 'skip') {
                willSkip++;
            } else {
                willUpdate++;
            }
        } else {
            willUpdate++;
        }
    }
    
    result.dryRunResults = { willUpdate, willSkip, willFail };
    return result;
}

3. Queue Service Integration (Optional for Large Batches)

File: backend/src/services/BulkOperationQueue.ts

import { Queue, Worker } from 'bullmq';
import { getRedisClient } from '../config/redis.config.js';

export interface IBulkSubscriptionJob {
    userId: number;
    tierId: number;
    endsAt: Date | null;
}

export class BulkOperationQueue {
    private static instance: BulkOperationQueue;
    private queue: Queue;
    private worker: Worker;
    
    private constructor() {
        const connection = getRedisClient();
        
        this.queue = new Queue('bulk-subscriptions', { connection });
        
        this.worker = new Worker(
            'bulk-subscriptions',
            async (job) => {
                const { userId, tierId, endsAt } = job.data as IBulkSubscriptionJob;
                const processor = UserSubscriptionProcessor.getInstance();
                return await processor.assignSubscription(userId, tierId, endsAt);
            },
            { connection }
        );
    }
    
    public static getInstance(): BulkOperationQueue {
        if (!BulkOperationQueue.instance) {
            BulkOperationQueue.instance = new BulkOperationQueue();
        }
        return BulkOperationQueue.instance;
    }
    
    async addBulkJob(userIds: number[], tierId: number, endsAt: Date | null) {
        const jobs = userIds.map(userId => ({
            name: `assign-subscription-${userId}`,
            data: { userId, tierId, endsAt }
        }));
        
        return await this.queue.addBulk(jobs);
    }
}

Acceptance Criteria

Functional Requirements

  • Admins can select multiple users via checkboxes in users list
  • "Select All" checkbox selects/deselects all visible users
  • Bulk actions bar appears when 1+ users selected
  • Bulk assignment modal displays selected user count
  • Tier dropdown populated from active tiers
  • Optional expiration date can be set for all users
  • Conflict strategy selection works correctly (replace/skip/fail)
  • Dry run mode previews changes without applying
  • Bulk assignment processes all selected users
  • Progress modal shows real-time updates during processing
  • Success/failure summary displayed after completion
  • Failed operations can be retried individually
  • Users list refreshes after successful bulk assignment

Performance Requirements

  • Can handle bulk assignment of 100 users in < 30 seconds
  • Can handle bulk assignment of 1000 users in < 5 minutes
  • Progress updates at least every 5 users or every 2 seconds
  • No browser freezing during bulk operations
  • Backend processes operations in batches to avoid timeout

Security Requirements

  • Admin-only access enforced on backend
  • JWT authentication required
  • Input validation on all fields
  • Rate limiting prevents abuse
  • Transaction safety prevents partial updates

Testing Requirements

Unit Tests

Backend:

// backend/src/processors/UserSubscriptionProcessor.test.ts
describe('bulkAssignSubscriptions', () => {
    it('assigns subscriptions to all users with replace strategy', async () => {
        // Test successful bulk assignment
    });
    
    it('skips users with existing subscriptions when strategy is skip', async () => {
        // Test skip strategy
    });
    
    it('fails when user has subscription and strategy is fail', async () => {
        // Test fail strategy
    });
    
    it('performs dry run without making changes', async () => {
        // Test dry run mode
    });
    
    it('handles partial failures gracefully', async () => {
        // Test error handling
    });
    
    it('returns detailed error messages for failed operations', async () => {
        // Test error reporting
    });
});

Frontend:

// frontend/tests/components/BulkSubscriptionAssignment.nuxt.test.ts
describe('BulkSubscriptionAssignmentModal', () => {
    it('renders with selected users', async () => {
        // Test rendering
    });
    
    it('validates tier selection before submission', async () => {
        // Test validation
    });
    
    it('displays dry run results correctly', async () => {
        // Test dry run preview
    });
    
    it('shows progress during bulk operation', async () => {
        // Test progress tracking
    });
});

Integration Tests

  • Select 10 users → Bulk assign PRO tier → Verify all updated
  • Bulk assign with skip strategy → Verify users with subscriptions skipped
  • Bulk assign with fail strategy → Verify operation fails if conflicts exist
  • Dry run mode → Verify no database changes made
  • Retry failed operations → Verify only failed users retried
  • Test with 100+ users to verify performance

Manual Testing Checklist

  • Select users via checkboxes
  • Use "Select All" functionality
  • Open bulk assignment modal
  • Select tier from dropdown
  • Set optional expiration date
  • Run dry run and verify preview
  • Execute bulk assignment
  • Monitor progress modal
  • Verify success summary
  • Check failed operations and retry
  • Verify users list updates correctly
  • Test all conflict strategies

Files to Create/Modify

Backend

  • Modify: backend/src/processors/UserSubscriptionProcessor.ts (add bulkAssignSubscriptions method)
  • Modify: backend/src/routes/admin/user-subscriptions.ts (add POST /bulk-assign-subscriptions)
  • Create: backend/src/services/BulkOperationQueue.ts (optional, for queue support)
  • Create: backend/src/tests/processors/UserSubscriptionProcessor.bulk.test.ts

Frontend

  • Modify: frontend/pages/admin/users/index.vue (add checkbox selection, bulk actions bar)
  • Create: frontend/components/admin/BulkSubscriptionAssignmentModal.vue
  • Create: frontend/components/admin/BulkOperationProgress.vue
  • Modify: frontend/stores/user_management.ts (add bulkAssignSubscriptions action)
  • Create: frontend/tests/components/BulkSubscriptionAssignment.nuxt.test.ts

Dependencies

Required

  • Existing subscription assignment infrastructure ✅
  • TypeORM transaction support ✅
  • Admin authentication ✅

Optional

  • BullMQ (Redis-based queue) for handling 500+ users
  • WebSocket support for real-time progress (can use polling instead)
  • CSV upload for bulk user selection (separate feature)

Alternatives Considered

  1. CSV Upload: Upload CSV with user IDs and tiers

    • Pros: More flexible, can include per-user customization
    • Cons: More complex, requires file parsing, harder UX
    • Decision: Postpone to Phase 2
  2. Background Job Processing: Queue all operations via BullMQ

    • Pros: Better performance for large batches (1000+ users)
    • Cons: Adds complexity, requires queue infrastructure
    • Decision: Implement for batches > 500 users
  3. GraphQL Mutation: Use GraphQL instead of REST endpoint

    • Pros: Better typed, can fetch specific fields in response
    • Cons: Project uses REST architecture currently
    • Decision: Stick with REST for consistency

Migration Plan

Phase 1: Core Functionality (v1.0)

  • Multi-select UI with checkboxes
  • Bulk assignment modal
  • Backend bulk endpoint (synchronous)
  • Progress tracking (polling-based)
  • Conflict strategies (replace/skip/fail)
  • Dry run mode
  • Support up to 500 users per batch

Phase 2: Advanced Features (v2.0)

  • Queue-based processing for 500+ users
  • WebSocket real-time progress
  • CSV upload for user selection
  • Scheduled bulk assignments
  • Audit log integration
  • Email notifications to affected users

Success Metrics

  1. Efficiency: Bulk assigning 100 users takes < 30 seconds (vs 30 minutes manual)
  2. Accuracy: 99%+ success rate for valid inputs
  3. Adoption: 60%+ of admin users use bulk assignment within first month
  4. Performance: Can handle 1000 users without timeout or browser freeze

Related Issues/PRs

  • Related to: User Subscription Management Implementation (✅ Complete)
  • Related to: Subscription History UI (helps verify bulk changes)
  • Blocks: Promotional Campaign Management (needs bulk assignment)

Additional Notes

  • Consider adding confirmation step for large batches (> 100 users)
  • Add undo/rollback feature for bulk operations (future enhancement)
  • Consider rate limiting bulk operations to prevent abuse
  • Log all bulk operations to audit log for compliance
  • Consider adding webhook notifications for external system integration

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions