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:
- Initial rollout - Assigning tiers to existing users after implementing subscription system
- Organizational changes - Upgrading/downgrading entire teams or departments
- Promotional campaigns - Bulk upgrading users for limited-time offers
- Testing/staging - Quickly setting up test scenarios with multiple users
- 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
Performance Requirements
Security Requirements
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
Manual Testing Checklist
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
-
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
-
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
-
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
- Efficiency: Bulk assigning 100 users takes < 30 seconds (vs 30 minutes manual)
- Accuracy: 99%+ success rate for valid inputs
- Adoption: 60%+ of admin users use bulk assignment within first month
- 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
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-operationsProblem 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:
Current workflow for 100 users:
Desired workflow:
Current State
Implemented
PUT /admin/users/:userId/subscriptionMissing
Proposed Solution
Architecture Overview
UI/UX Design
1. Users List Enhancement
File:
frontend/pages/admin/users/index.vue2. Bulk Assignment Modal
Component:
frontend/components/admin/BulkSubscriptionAssignmentModal.vue3. Progress Tracking Modal
Component:
frontend/components/admin/BulkOperationProgress.vueTechnical Implementation
Backend Implementation
1. New Bulk Assignment Endpoint
File:
backend/src/routes/admin/user-subscriptions.ts2. UserSubscriptionProcessor Enhancement
File:
backend/src/processors/UserSubscriptionProcessor.ts3. Queue Service Integration (Optional for Large Batches)
File:
backend/src/services/BulkOperationQueue.tsAcceptance Criteria
Functional Requirements
Performance Requirements
Security Requirements
Testing Requirements
Unit Tests
Backend:
Frontend:
Integration Tests
Manual Testing Checklist
Files to Create/Modify
Backend
backend/src/processors/UserSubscriptionProcessor.ts(add bulkAssignSubscriptions method)backend/src/routes/admin/user-subscriptions.ts(add POST /bulk-assign-subscriptions)backend/src/services/BulkOperationQueue.ts(optional, for queue support)backend/src/tests/processors/UserSubscriptionProcessor.bulk.test.tsFrontend
frontend/pages/admin/users/index.vue(add checkbox selection, bulk actions bar)frontend/components/admin/BulkSubscriptionAssignmentModal.vuefrontend/components/admin/BulkOperationProgress.vuefrontend/stores/user_management.ts(add bulkAssignSubscriptions action)frontend/tests/components/BulkSubscriptionAssignment.nuxt.test.tsDependencies
Required
Optional
Alternatives Considered
CSV Upload: Upload CSV with user IDs and tiers
Background Job Processing: Queue all operations via BullMQ
GraphQL Mutation: Use GraphQL instead of REST endpoint
Migration Plan
Phase 1: Core Functionality (v1.0)
Phase 2: Advanced Features (v2.0)
Success Metrics
Related Issues/PRs
Additional Notes