Skip to content

Commit d4572e5

Browse files
committed
feat: implement training video backfill functionality
- Added scripts and server actions to trigger training video completion backfill for all organizations or specific organizations. - Created jobs to handle backfilling of training video records for existing members in organizations. - Enhanced employee onboarding processes by ensuring new members have training video completion entries created upon invitation or organization creation. - Updated README with usage instructions and details on the backfill jobs. This implementation improves the tracking of training video completions for all members, ensuring accurate data representation in the system.
1 parent 4d3decf commit d4572e5

File tree

11 files changed

+495
-10
lines changed

11 files changed

+495
-10
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
#!/usr/bin/env tsx
2+
3+
/**
4+
* Script to trigger the training video completion backfill job.
5+
*
6+
* Usage:
7+
* # Backfill all organizations
8+
* bun run scripts/backfill-training-videos.ts
9+
*
10+
* # Backfill specific organization
11+
* bun run scripts/backfill-training-videos.ts --org <organizationId>
12+
*
13+
* This script is useful for:
14+
* - Running the backfill manually
15+
* - Testing the backfill process
16+
* - Running on-demand backfills for specific organizations
17+
*/
18+
19+
import { backfillTrainingVideosForAllOrgs } from '@/jobs/tasks/onboarding/backfill-training-videos-for-all-orgs';
20+
import { backfillTrainingVideosForOrg } from '@/jobs/tasks/onboarding/backfill-training-videos-for-org';
21+
22+
async function main() {
23+
const args = process.argv.slice(2);
24+
const orgIndex = args.indexOf('--org');
25+
const organizationId = orgIndex !== -1 ? args[orgIndex + 1] : null;
26+
27+
try {
28+
if (organizationId) {
29+
console.log(`🚀 Triggering training video backfill for organization: ${organizationId}`);
30+
31+
const handle = await backfillTrainingVideosForOrg.trigger({
32+
organizationId: organizationId,
33+
});
34+
35+
console.log(`✅ Successfully triggered job with ID: ${handle.id}`);
36+
console.log(`📊 You can monitor the progress in the Trigger.dev dashboard`);
37+
} else {
38+
console.log('🚀 Triggering training video backfill for ALL organizations');
39+
40+
const handle = await backfillTrainingVideosForAllOrgs.trigger();
41+
42+
console.log(`✅ Successfully triggered batch job with ID: ${handle.id}`);
43+
console.log(`📊 You can monitor the progress in the Trigger.dev dashboard`);
44+
console.log(`⚠️ This will process ALL organizations and their members`);
45+
}
46+
} catch (error) {
47+
console.error('❌ Error triggering backfill job:', error);
48+
process.exit(1);
49+
}
50+
}
51+
52+
// Only run if this script is executed directly
53+
if (require.main === module) {
54+
main().catch((error) => {
55+
console.error('❌ Script failed:', error);
56+
process.exit(1);
57+
});
58+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
'use server';
2+
3+
import { authActionClient } from '@/actions/safe-action';
4+
import { backfillTrainingVideosForAllOrgs } from '@/jobs/tasks/onboarding/backfill-training-videos-for-all-orgs';
5+
import { backfillTrainingVideosForOrg } from '@/jobs/tasks/onboarding/backfill-training-videos-for-org';
6+
import { z } from 'zod';
7+
import type { ActionResponse } from '../types';
8+
9+
const triggerBackfillSchema = z.object({
10+
organizationId: z.string().optional(),
11+
});
12+
13+
export const triggerTrainingVideoBackfill = authActionClient
14+
.metadata({
15+
name: 'trigger-training-video-backfill',
16+
track: {
17+
event: 'trigger_training_video_backfill',
18+
channel: 'admin',
19+
},
20+
})
21+
.inputSchema(triggerBackfillSchema)
22+
.action(
23+
async ({
24+
parsedInput,
25+
ctx,
26+
}): Promise<
27+
ActionResponse<{
28+
triggered: boolean;
29+
jobType: 'single-org' | 'all-orgs';
30+
organizationId?: string;
31+
}>
32+
> => {
33+
try {
34+
// Check if user has admin permissions (you may want to add additional checks)
35+
const member = await ctx.db.member.findFirst({
36+
where: {
37+
userId: ctx.user.id,
38+
organizationId: ctx.session.activeOrganizationId,
39+
},
40+
});
41+
42+
if (!member || (!member.role.includes('admin') && !member.role.includes('owner'))) {
43+
return {
44+
success: false,
45+
error: 'Insufficient permissions. Admin or owner role required.',
46+
};
47+
}
48+
49+
if (parsedInput.organizationId) {
50+
// Trigger backfill for a specific organization
51+
await backfillTrainingVideosForOrg.trigger({
52+
organizationId: parsedInput.organizationId,
53+
});
54+
55+
return {
56+
success: true,
57+
data: {
58+
triggered: true,
59+
jobType: 'single-org',
60+
organizationId: parsedInput.organizationId,
61+
},
62+
};
63+
} else {
64+
// Trigger backfill for all organizations
65+
await backfillTrainingVideosForAllOrgs.trigger();
66+
67+
return {
68+
success: true,
69+
data: {
70+
triggered: true,
71+
jobType: 'all-orgs',
72+
},
73+
};
74+
}
75+
} catch (error) {
76+
console.error('Error triggering training video backfill:', error);
77+
const errorMessage =
78+
error instanceof Error ? error.message : 'Failed to trigger backfill job';
79+
return {
80+
success: false,
81+
error: errorMessage,
82+
};
83+
}
84+
},
85+
);

apps/app/src/actions/organization/accept-invitation.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use server';
22

3+
import { createTrainingVideoEntries } from '@/lib/db/employee';
34
import { db } from '@db';
45
import { revalidatePath, revalidateTag } from 'next/cache';
56
import { redirect } from 'next/navigation';
@@ -96,7 +97,7 @@ export const completeInvitation = authActionClientWithoutOrg
9697
throw new Error('Invitation role is required');
9798
}
9899

99-
await db.member.create({
100+
const newMember = await db.member.create({
100101
data: {
101102
userId: user.id,
102103
organizationId: invitation.organizationId,
@@ -105,6 +106,9 @@ export const completeInvitation = authActionClientWithoutOrg
105106
},
106107
});
107108

109+
// Create training video completion entries for the new member
110+
await createTrainingVideoEntries(newMember.id);
111+
108112
await db.invitation.update({
109113
where: {
110114
id: invitation.id,

apps/app/src/app/(app)/[orgId]/people/all/actions/addEmployeeWithoutInvite.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use server';
22

3+
import { createTrainingVideoEntries } from '@/lib/db/employee';
34
import { auth } from '@/utils/auth';
45
import type { Role } from '@db';
56
import { db } from '@db';
@@ -61,6 +62,11 @@ export const addEmployeeWithoutInvite = async ({
6162
},
6263
});
6364

65+
// Create training video completion entries for the new member
66+
if (member?.id) {
67+
await createTrainingVideoEntries(member.id);
68+
}
69+
6470
return { success: true, data: member };
6571
} catch (error) {
6672
console.error('Error adding employee:', error);

apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -228,10 +228,15 @@ export function EmployeeCompletionChart({
228228
</div>
229229
<p className="text-muted-foreground text-xs">{stat.email}</p>
230230
</div>
231-
<span className="text-muted-foreground">
232-
{stat.policiesCompleted + stat.trainingsCompleted} / {stat.totalTasks}{' '}
233-
{'tasks'}
234-
</span>
231+
<div className="text-muted-foreground text-right text-xs">
232+
<div>
233+
{stat.policiesCompleted + stat.trainingsCompleted} / {stat.totalTasks} tasks
234+
</div>
235+
<div className="text-xs">
236+
{stat.policiesCompleted}/{stat.policiesTotal} policies •{' '}
237+
{stat.trainingsCompleted}/{stat.trainingsTotal} training
238+
</div>
239+
</div>
235240
</div>
236241

237242
<TaskBarChart stat={stat} />

apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { initializeOrganization } from '@/actions/organization/lib/initialize-organization';
44
import { authActionClientWithoutOrg } from '@/actions/safe-action';
5+
import { createTrainingVideoEntries } from '@/lib/db/employee';
56
import { auth } from '@/utils/auth';
67
import { db } from '@db';
78
import { revalidatePath } from 'next/cache';
@@ -64,6 +65,19 @@ export const createOrganizationMinimal = authActionClientWithoutOrg
6465

6566
const orgId = newOrg.id;
6667

68+
// Get the member that was created with the organization (the owner)
69+
const ownerMember = await db.member.findFirst({
70+
where: {
71+
userId: session.user.id,
72+
organizationId: orgId,
73+
},
74+
});
75+
76+
// Create training video completion entries for the owner
77+
if (ownerMember) {
78+
await createTrainingVideoEntries(ownerMember.id);
79+
}
80+
6781
// Create onboarding record for new org
6882
await db.onboarding.create({
6983
data: {

apps/app/src/app/(app)/setup/actions/create-organization.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { initializeOrganization } from '@/actions/organization/lib/initialize-or
44
import { authActionClientWithoutOrg } from '@/actions/safe-action';
55
import { createFleetLabelForOrg } from '@/jobs/tasks/device/create-fleet-label-for-org';
66
import { onboardOrganization as onboardOrganizationTask } from '@/jobs/tasks/onboarding/onboard-organization';
7+
import { createTrainingVideoEntries } from '@/lib/db/employee';
78
import { auth } from '@/utils/auth';
89
import { db } from '@db';
910
import { tasks } from '@trigger.dev/sdk';
@@ -63,6 +64,19 @@ export const createOrganization = authActionClientWithoutOrg
6364

6465
const orgId = newOrg.id;
6566

67+
// Get the member that was created with the organization (the owner)
68+
const ownerMember = await db.member.findFirst({
69+
where: {
70+
userId: session.user.id,
71+
organizationId: orgId,
72+
},
73+
});
74+
75+
// Create training video completion entries for the owner
76+
if (ownerMember) {
77+
await createTrainingVideoEntries(ownerMember.id);
78+
}
79+
6680
// Create onboarding record for new org
6781
await db.onboarding.create({
6882
data: {
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Training Video Completion Backfill Jobs
2+
3+
This directory contains Trigger.dev jobs to backfill training video completion records for existing organizations and members.
4+
5+
## Overview
6+
7+
When the training video completion tracking feature was implemented, existing members in organizations did not have the required `EmployeeTrainingVideoCompletion` records. These jobs ensure all existing members have proper training video completion tracking.
8+
9+
## Jobs
10+
11+
### 1. `backfill-training-videos-for-all-orgs`
12+
13+
- **Purpose**: Processes all organizations in the system
14+
- **Trigger ID**: `backfill-training-videos-for-all-orgs`
15+
- **Behavior**:
16+
- Finds all organizations
17+
- Creates batch jobs for each organization
18+
- Uses `batchTrigger` to process organizations in parallel
19+
20+
### 2. `backfill-training-videos-for-org`
21+
22+
- **Purpose**: Processes a single organization
23+
- **Trigger ID**: `backfill-training-videos-for-org`
24+
- **Payload**: `{ organizationId: string }`
25+
- **Behavior**:
26+
- Finds all members in the organization
27+
- Creates `EmployeeTrainingVideoCompletion` records for each member
28+
- Uses `skipDuplicates: true` to prevent duplicate records
29+
- Processes each member individually with error handling
30+
31+
## Duplicate Prevention
32+
33+
Both jobs use `skipDuplicates: true` when creating records, which means:
34+
35+
- ✅ Safe to run multiple times
36+
- ✅ Won't create duplicate records
37+
- ✅ Only creates missing records
38+
39+
## Usage
40+
41+
### Option 1: Via Script (Recommended for testing)
42+
43+
```bash
44+
# Backfill all organizations
45+
bun run scripts/backfill-training-videos.ts
46+
47+
# Backfill specific organization
48+
bun run scripts/backfill-training-videos.ts --org org_123456789
49+
```
50+
51+
### Option 2: Via Server Action (For admin UI)
52+
53+
```typescript
54+
import { triggerTrainingVideoBackfill } from '@/actions/admin/trigger-training-video-backfill';
55+
56+
// Backfill all organizations
57+
await triggerTrainingVideoBackfill({ organizationId: undefined });
58+
59+
// Backfill specific organization
60+
await triggerTrainingVideoBackfill({ organizationId: 'org_123456789' });
61+
```
62+
63+
### Option 3: Direct Trigger.dev API
64+
65+
```typescript
66+
import { backfillTrainingVideosForAllOrgs } from '@/jobs/tasks/onboarding/backfill-training-videos-for-all-orgs';
67+
68+
await backfillTrainingVideosForAllOrgs.trigger();
69+
```
70+
71+
## Monitoring
72+
73+
- Monitor job progress in the Trigger.dev dashboard
74+
- Each job provides detailed logging including:
75+
- Number of organizations processed
76+
- Number of members processed per organization
77+
- Number of records created
78+
- Error details for any failures
79+
80+
## Expected Results
81+
82+
After running the backfill:
83+
84+
- All existing members will have `EmployeeTrainingVideoCompletion` records
85+
- Records will have `completedAt: null` (indicating not yet completed)
86+
- Employee progress charts will show accurate data
87+
- Training video tracking will work correctly for all members
88+
89+
## Safety Features
90+
91+
- **Idempotent**: Safe to run multiple times
92+
- **Error Isolation**: Failure processing one member doesn't stop others
93+
- **Comprehensive Logging**: Full audit trail of what was processed
94+
- **Permission Checks**: Admin/owner permissions required for triggers
95+
- **Batch Processing**: Efficient processing of large datasets
96+
97+
## Database Impact
98+
99+
- Creates records in `EmployeeTrainingVideoCompletion` table
100+
- Number of records = (Number of members) × (Number of training videos)
101+
- Current training videos: 5 (sat-1 through sat-5)
102+
- Uses database transactions for consistency
103+
104+
## Rollback
105+
106+
If you need to remove the backfilled records:
107+
108+
```sql
109+
-- Remove all training video completion records with null completedAt
110+
DELETE FROM "EmployeeTrainingVideoCompletion"
111+
WHERE "completedAt" IS NULL;
112+
```
113+
114+
⚠️ **Warning**: Only run this if you're sure you want to remove ALL incomplete training records.

0 commit comments

Comments
 (0)