From ff3f6f7a86eca849617722500205bb9f47c2803b Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Tue, 26 Aug 2025 13:10:30 -0400 Subject: [PATCH 01/16] refactor: enhance EmployeeCompletionChart with profile links - Added external profile links for employees in the EmployeeCompletionChart, allowing users to view detailed profiles. - Integrated organization ID retrieval using useParams for dynamic linking. - Improved layout of employee details for better visual presentation. --- .../components/EmployeeCompletionChart.tsx | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx index 55ff38f56..a2c820684 100644 --- a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx @@ -2,7 +2,9 @@ import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card'; import { Input } from '@comp/ui/input'; -import { Search } from 'lucide-react'; +import { ExternalLink, Search } from 'lucide-react'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; import type { CSSProperties } from 'react'; import * as React from 'react'; @@ -47,6 +49,8 @@ export function EmployeeCompletionChart({ trainingVideos, showAll = false, }: EmployeeCompletionChartProps) { + const params = useParams(); + const orgId = params.orgId as string; const [searchTerm, setSearchTerm] = React.useState(''); const [displayedItems, setDisplayedItems] = React.useState(showAll ? 20 : 5); const [isLoading, setIsLoading] = React.useState(false); @@ -209,8 +213,19 @@ export function EmployeeCompletionChart({ {sortedStats.map((stat) => (
-
-

{stat.name}

+
+
+

{stat.name}

+ + View Profile + + +

{stat.email}

From 8a81f42b22da29a7a39e2b4698e94421a6a329fd Mon Sep 17 00:00:00 2001 From: Dhanus Date: Wed, 27 Aug 2025 00:11:24 +0530 Subject: [PATCH 02/16] fix: Enforce role-based access control in app --- apps/app/src/app/(app)/layout.tsx | 27 +++++++++++++++++++++++++++ apps/app/src/app/page.tsx | 4 ---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/app/src/app/(app)/layout.tsx b/apps/app/src/app/(app)/layout.tsx index 8fd5117a1..30379b8bf 100644 --- a/apps/app/src/app/(app)/layout.tsx +++ b/apps/app/src/app/(app)/layout.tsx @@ -30,5 +30,32 @@ export default async function Layout({ children }: { children: React.ReactNode } } } + const { activeOrganizationId } = session.session; + const { id: userId } = session.user; + + if (activeOrganizationId) { + const currentUserMember = await db.member.findFirst({ + where: { + organizationId: activeOrganizationId, + userId: userId, + }, + select: { + role: true, + }, + }); + + const isAuthorized = + currentUserMember && + (currentUserMember.role.includes('admin') || currentUserMember.role.includes('owner')); + + if (!isAuthorized) { + const currentPath = hdrs.get('x-pathname') || ''; + + if (currentPath !== '/no-access') { + return redirect('/no-access'); + } + } + } + return <>{children}; } diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx index 61552c2c6..c83d58de7 100644 --- a/apps/app/src/app/page.tsx +++ b/apps/app/src/app/page.tsx @@ -72,10 +72,6 @@ export default async function RootPage({ }, }); - if (member?.role === 'employee') { - return redirect(await buildUrlWithParams('/no-access')); - } - if (!member) { return redirect(await buildUrlWithParams('/setup')); } From a9957cd3daf9d15430d1c47c45ae4e6139e47b92 Mon Sep 17 00:00:00 2001 From: Dhanus Date: Wed, 27 Aug 2025 00:21:14 +0530 Subject: [PATCH 03/16] fix: Prisma seed command in `packages/db` --- packages/db/package.json | 2 +- .../db/prisma/seed/frameworkEditorSchemas.js | 136 --------------- packages/db/prisma/seed/seed.js | 160 ------------------ 3 files changed, 1 insertion(+), 297 deletions(-) delete mode 100644 packages/db/prisma/seed/frameworkEditorSchemas.js delete mode 100644 packages/db/prisma/seed/seed.js diff --git a/packages/db/package.json b/packages/db/package.json index 993261b7a..9ed8a996f 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -34,7 +34,7 @@ "db:generate": "prisma generate", "db:migrate": "prisma migrate dev", "db:push": "prisma db push", - "db:seed": "prisma db seed", + "db:seed": "bun prisma/seed/seed.ts", "db:studio": "prisma studio", "docker:clean": "docker compose down -v", "docker:down": "docker compose down", diff --git a/packages/db/prisma/seed/frameworkEditorSchemas.js b/packages/db/prisma/seed/frameworkEditorSchemas.js deleted file mode 100644 index 28709b66b..000000000 --- a/packages/db/prisma/seed/frameworkEditorSchemas.js +++ /dev/null @@ -1,136 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.frameworkEditorModelSchemas = exports.FrameworkEditorControlTemplateSchema = exports.FrameworkEditorTaskTemplateSchema = exports.FrameworkEditorPolicyTemplateSchema = exports.FrameworkEditorRequirementSchema = exports.FrameworkEditorFrameworkSchema = exports.FrameworkEditorVideoSchema = void 0; -const zod_1 = require("zod"); -// Assuming Frequency and Departments enums are defined elsewhere and imported -// For now, we'll use z.string() as a placeholder if their definitions aren't available. -// import { Frequency, Departments } from './path-to-shared-enums'; // Example import -const datePreprocess = (arg) => { - if (typeof arg === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}$/.test(arg)) { - return arg.replace(' ', 'T') + 'Z'; - } - return arg; -}; -exports.FrameworkEditorVideoSchema = zod_1.z.object({ - id: zod_1.z.string().optional(), // @id @default - title: zod_1.z.string(), - description: zod_1.z.string(), - youtubeId: zod_1.z.string(), - url: zod_1.z.string(), - createdAt: zod_1.z - .preprocess(datePreprocess, zod_1.z.string().datetime({ - message: 'Invalid datetime string for createdAt. Expected ISO 8601 format.', - })) - .optional(), // @default(now()) - updatedAt: zod_1.z - .preprocess(datePreprocess, zod_1.z.string().datetime({ - message: 'Invalid datetime string for updatedAt. Expected ISO 8601 format.', - })) - .optional(), // @default(now()) @updatedAt -}); -exports.FrameworkEditorFrameworkSchema = zod_1.z.object({ - id: zod_1.z.string().optional(), // @id @default - name: zod_1.z.string(), - version: zod_1.z.string(), - description: zod_1.z.string(), - visible: zod_1.z.boolean().optional(), // @default(true) - // requirements: FrameworkEditorRequirement[] - relational, omitted - // frameworkInstances: FrameworkInstance[] - relational, omitted - createdAt: zod_1.z - .preprocess(datePreprocess, zod_1.z.string().datetime({ - message: 'Invalid datetime string for createdAt. Expected ISO 8601 format.', - })) - .optional(), // @default(now()) - updatedAt: zod_1.z - .preprocess(datePreprocess, zod_1.z.string().datetime({ - message: 'Invalid datetime string for updatedAt. Expected ISO 8601 format.', - })) - .optional(), // @default(now()) @updatedAt -}); -exports.FrameworkEditorRequirementSchema = zod_1.z.object({ - id: zod_1.z.string().optional(), // @id @default - frameworkId: zod_1.z.string(), - // framework: FrameworkEditorFramework - relational, omitted - name: zod_1.z.string(), - identifier: zod_1.z.string().optional(), // @default("") - description: zod_1.z.string(), - // controlTemplates: FrameworkEditorControlTemplate[] - relational, omitted - // requirementMaps: RequirementMap[] - relational, omitted - createdAt: zod_1.z - .preprocess(datePreprocess, zod_1.z.string().datetime({ - message: 'Invalid datetime string for createdAt. Expected ISO 8601 format.', - })) - .optional(), // @default(now()) - updatedAt: zod_1.z - .preprocess(datePreprocess, zod_1.z.string().datetime({ - message: 'Invalid datetime string for updatedAt. Expected ISO 8601 format.', - })) - .optional(), // @default(now()) @updatedAt -}); -exports.FrameworkEditorPolicyTemplateSchema = zod_1.z.object({ - id: zod_1.z.string().optional(), // @id @default - name: zod_1.z.string(), - description: zod_1.z.string(), - frequency: zod_1.z.string(), // Placeholder for Frequency enum - department: zod_1.z.string(), // Placeholder for Departments enum - content: zod_1.z.any(), // Json - // controlTemplates: FrameworkEditorControlTemplate[] - relational, omitted - createdAt: zod_1.z - .preprocess(datePreprocess, zod_1.z.string().datetime({ - message: 'Invalid datetime string for createdAt. Expected ISO 8601 format.', - })) - .optional(), // @default(now()) - updatedAt: zod_1.z - .preprocess(datePreprocess, zod_1.z.string().datetime({ - message: 'Invalid datetime string for updatedAt. Expected ISO 8601 format.', - })) - .optional(), // @default(now()) @updatedAt - // policies: Policy[] - relational, omitted -}); -exports.FrameworkEditorTaskTemplateSchema = zod_1.z.object({ - id: zod_1.z.string().optional(), // @id @default - name: zod_1.z.string(), - description: zod_1.z.string(), - frequency: zod_1.z.string(), // Placeholder for Frequency enum - department: zod_1.z.string(), // Placeholder for Departments enum - // controlTemplates: FrameworkEditorControlTemplate[] - relational, omitted - createdAt: zod_1.z - .preprocess(datePreprocess, zod_1.z.string().datetime({ - message: 'Invalid datetime string for createdAt. Expected ISO 8601 format.', - })) - .optional(), // @default(now()) - updatedAt: zod_1.z - .preprocess(datePreprocess, zod_1.z.string().datetime({ - message: 'Invalid datetime string for updatedAt. Expected ISO 8601 format.', - })) - .optional(), // @default(now()) @updatedAt - // tasks: Task[] - relational, omitted -}); -exports.FrameworkEditorControlTemplateSchema = zod_1.z.object({ - id: zod_1.z.string().optional(), // @id @default - name: zod_1.z.string(), - description: zod_1.z.string(), - // policyTemplates: FrameworkEditorPolicyTemplate[] - relational, omitted - // requirements: FrameworkEditorRequirement[] - relational, omitted - // taskTemplates: FrameworkEditorTaskTemplate[] - relational, omitted - createdAt: zod_1.z - .preprocess(datePreprocess, zod_1.z.string().datetime({ - message: 'Invalid datetime string for createdAt. Expected ISO 8601 format.', - })) - .optional(), // @default(now()) - updatedAt: zod_1.z - .preprocess(datePreprocess, zod_1.z.string().datetime({ - message: 'Invalid datetime string for updatedAt. Expected ISO 8601 format.', - })) - .optional(), // @default(now()) @updatedAt - // controls: Control[] - relational, omitted -}); -// For use in seed script validation -exports.frameworkEditorModelSchemas = { - FrameworkEditorVideo: exports.FrameworkEditorVideoSchema, - FrameworkEditorFramework: exports.FrameworkEditorFrameworkSchema, - FrameworkEditorRequirement: exports.FrameworkEditorRequirementSchema, - FrameworkEditorPolicyTemplate: exports.FrameworkEditorPolicyTemplateSchema, - FrameworkEditorTaskTemplate: exports.FrameworkEditorTaskTemplateSchema, - FrameworkEditorControlTemplate: exports.FrameworkEditorControlTemplateSchema, -}; diff --git a/packages/db/prisma/seed/seed.js b/packages/db/prisma/seed/seed.js deleted file mode 100644 index 4836087ad..000000000 --- a/packages/db/prisma/seed/seed.js +++ /dev/null @@ -1,160 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const client_1 = require("@prisma/client"); -const promises_1 = __importDefault(require("node:fs/promises")); -const node_path_1 = __importDefault(require("node:path")); -const frameworkEditorSchemas_1 = require("./frameworkEditorSchemas"); -const prisma = new client_1.PrismaClient(); -async function seedJsonFiles(subDirectory) { - const directoryPath = node_path_1.default.join(__dirname, subDirectory); - console.log(`Starting to seed files from: ${directoryPath}`); - const files = await promises_1.default.readdir(directoryPath); - const jsonFiles = files.filter((file) => file.endsWith('.json')); - for (const jsonFile of jsonFiles) { - try { - const filePath = node_path_1.default.join(directoryPath, jsonFile); - const jsonContent = await promises_1.default.readFile(filePath, 'utf-8'); - const jsonData = JSON.parse(jsonContent); - if (!Array.isArray(jsonData) || jsonData.length === 0) { - console.log(`Skipping empty or invalid JSON file: ${jsonFile}`); - continue; - } - if (subDirectory === 'primitives') { - const modelNameForPrisma = jsonFile.replace('.json', ''); - const prismaModelKey = modelNameForPrisma.charAt(0).toLowerCase() + modelNameForPrisma.slice(1); - const zodModelKey = modelNameForPrisma; - const prismaAny = prisma; - if (!prismaAny[prismaModelKey] || - typeof prismaAny[prismaModelKey].createMany !== 'function') { - console.warn(`Model ${prismaModelKey} not found on Prisma client or does not support createMany. Skipping ${jsonFile}.`); - continue; - } - const zodSchema = frameworkEditorSchemas_1.frameworkEditorModelSchemas[zodModelKey]; - if (!zodSchema) { - console.warn(`Zod schema not found for model ${String(zodModelKey)}. Skipping validation for ${jsonFile}.`); - } - else { - console.log(`Validating ${jsonData.length} records from ${jsonFile} against ${String(zodModelKey)} schema...`); - for (const item of jsonData) { - try { - zodSchema.parse(item); - } - catch (validationError) { - console.error(`Validation failed for an item in ${jsonFile} for model ${String(zodModelKey)}:`, item); - console.error('Validation errors:', validationError); - throw new Error(`Data validation failed for ${jsonFile}.`); - } - } - console.log(`Validation successful for ${jsonFile}.`); - } - const processedData = jsonData.map((item) => { - const newItem = { ...item }; - if (newItem.createdAt && typeof newItem.createdAt === 'string') { - newItem.createdAt = new Date(newItem.createdAt); - } - if (newItem.updatedAt && typeof newItem.updatedAt === 'string') { - newItem.updatedAt = new Date(newItem.updatedAt); - } - return newItem; - }); - console.log(`Seeding ${processedData.length} records from ${jsonFile} into ${prismaModelKey}...`); - // Use upsert to update existing records instead of skipping them - for (const record of processedData) { - await prismaAny[prismaModelKey].upsert({ - where: { id: record.id }, - create: record, - update: record, - }); - } - console.log(`Finished seeding ${jsonFile} from primitives.`); - } - else if (subDirectory === 'relations') { - // Expected filename format: _ModelAToModelB.json - if (!jsonFile.startsWith('_') || !jsonFile.includes('To')) { - console.warn(`Skipping relation file with unexpected format: ${jsonFile}`); - continue; - } - const modelNamesPart = jsonFile.substring(1, jsonFile.indexOf('.json')); - const [modelANamePascal, modelBNamePascal] = modelNamesPart.split('To'); - if (!modelANamePascal || !modelBNamePascal) { - console.warn(`Could not parse model names from relation file: ${jsonFile}`); - continue; - } - const prismaModelAName = modelANamePascal.charAt(0).toLowerCase() + modelANamePascal.slice(1); - // Infer relation field name on ModelA: pluralized, camelCased ModelB name - // e.g., if ModelB is FrameworkEditorPolicyTemplate, relation field is frameworkEditorPolicyTemplates - // This is a common convention, but might need adjustment based on actual schema - let relationFieldNameOnModelA = modelBNamePascal.charAt(0).toLowerCase() + modelBNamePascal.slice(1); - if (!relationFieldNameOnModelA.endsWith('s')) { - // basic pluralization - relationFieldNameOnModelA += 's'; - } - // Special handling for 'Requirement' -> 'requirements' (already plural) - // and other specific cases if 's' isn't the right pluralization. - // For now, using a direct map for known cases from the user's file names. - if (modelBNamePascal === 'FrameworkEditorPolicyTemplate') { - relationFieldNameOnModelA = 'policyTemplates'; - } - else if (modelBNamePascal === 'FrameworkEditorRequirement') { - relationFieldNameOnModelA = 'requirements'; - } - else if (modelBNamePascal === 'FrameworkEditorTaskTemplate') { - relationFieldNameOnModelA = 'taskTemplates'; - } - const prismaAny = prisma; - if (!prismaAny[prismaModelAName] || - typeof prismaAny[prismaModelAName].update !== 'function') { - console.warn(`Model ${prismaModelAName} not found on Prisma client or does not support update. Skipping ${jsonFile}.`); - continue; - } - console.log(`Processing relations from ${jsonFile} for ${prismaModelAName} to connect via ${relationFieldNameOnModelA}...`); - let connectionsMade = 0; - for (const relationItem of jsonData) { - if (!relationItem.A || !relationItem.B) { - console.warn(`Skipping invalid relation item in ${jsonFile}:`, relationItem); - continue; - } - const idA = relationItem.A; - const idB = relationItem.B; - try { - await prismaAny[prismaModelAName].update({ - where: { id: idA }, - data: { - [relationFieldNameOnModelA]: { - connect: { id: idB }, - }, - }, - }); - connectionsMade++; - } - catch (error) { - console.error(`Failed to connect ${prismaModelAName} (${idA}) with ${modelBNamePascal} (${idB}) from ${jsonFile}:`, error); - // Decide if one error should stop the whole process for this file or continue - } - } - console.log(`Finished processing ${jsonFile}. Made ${connectionsMade} connections.`); - } - } - catch (error) { - console.error(`Error processing ${jsonFile}:`, error); - throw error; - } - } -} -async function main() { - try { - await seedJsonFiles('primitives'); - await seedJsonFiles('relations'); - await prisma.$disconnect(); - console.log('Seeding completed successfully for primitives and relations.'); - } - catch (error) { - console.error('Seeding failed:', error); - await prisma.$disconnect(); - process.exit(1); - } -} -main(); From a4056bf4d904baf450fd07c4e7ce0f83100e3504 Mon Sep 17 00:00:00 2001 From: Dhanus Date: Wed, 27 Aug 2025 23:52:54 +0530 Subject: [PATCH 04/16] fix: Move role checks on org level --- apps/app/src/app/(app)/[orgId]/layout.tsx | 7 ++++++ apps/app/src/app/(app)/layout.tsx | 27 ----------------------- 2 files changed, 7 insertions(+), 27 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/layout.tsx index 6bc8fb313..9a80ba8e6 100644 --- a/apps/app/src/app/(app)/[orgId]/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/layout.tsx @@ -63,6 +63,13 @@ export default async function Layout({ return redirect('/auth/unauthorized'); } + const isAuthorized = member.role.includes('admin') || member.role.includes('owner'); + + // If user is not authorized, redirect to no-access page + if (!isAuthorized) { + return redirect('/no-access'); + } + // If this org is not accessible on current plan, redirect to upgrade if (!organization.hasAccess) { return redirect(`/upgrade/${organization.id}`); diff --git a/apps/app/src/app/(app)/layout.tsx b/apps/app/src/app/(app)/layout.tsx index 30379b8bf..8fd5117a1 100644 --- a/apps/app/src/app/(app)/layout.tsx +++ b/apps/app/src/app/(app)/layout.tsx @@ -30,32 +30,5 @@ export default async function Layout({ children }: { children: React.ReactNode } } } - const { activeOrganizationId } = session.session; - const { id: userId } = session.user; - - if (activeOrganizationId) { - const currentUserMember = await db.member.findFirst({ - where: { - organizationId: activeOrganizationId, - userId: userId, - }, - select: { - role: true, - }, - }); - - const isAuthorized = - currentUserMember && - (currentUserMember.role.includes('admin') || currentUserMember.role.includes('owner')); - - if (!isAuthorized) { - const currentPath = hdrs.get('x-pathname') || ''; - - if (currentPath !== '/no-access') { - return redirect('/no-access'); - } - } - } - return <>{children}; } From 155340017c62a09dcfd03709011e25df89a85863 Mon Sep 17 00:00:00 2001 From: Dhanus Date: Thu, 28 Aug 2025 00:08:12 +0530 Subject: [PATCH 05/16] fix: Allow access to auditor role --- apps/app/src/app/(app)/[orgId]/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/app/(app)/[orgId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/layout.tsx index 9a80ba8e6..ed0f92674 100644 --- a/apps/app/src/app/(app)/[orgId]/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/layout.tsx @@ -63,7 +63,7 @@ export default async function Layout({ return redirect('/auth/unauthorized'); } - const isAuthorized = member.role.includes('admin') || member.role.includes('owner'); + const isAuthorized = ['admin', 'owner', 'auditor'].includes(member.role); // If user is not authorized, redirect to no-access page if (!isAuthorized) { From bfa18f00b0dfe77026ec37aa6a771adea425f3d5 Mon Sep 17 00:00:00 2001 From: Dhanus Date: Thu, 28 Aug 2025 00:13:32 +0530 Subject: [PATCH 06/16] chore: Just restrict access to employee role --- apps/app/src/app/(app)/[orgId]/layout.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/layout.tsx index ed0f92674..3bb85b700 100644 --- a/apps/app/src/app/(app)/[orgId]/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/layout.tsx @@ -63,10 +63,7 @@ export default async function Layout({ return redirect('/auth/unauthorized'); } - const isAuthorized = ['admin', 'owner', 'auditor'].includes(member.role); - - // If user is not authorized, redirect to no-access page - if (!isAuthorized) { + if (member.role === 'employee') { return redirect('/no-access'); } From f16ad2f9d9d1a30b041f3fae5df570e149f12e2f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 28 Aug 2025 11:22:38 -0400 Subject: [PATCH 07/16] chore(deps): bump @tiptap/extension-highlight from 2.22.3 to 3.3.0 (#1404) Bumps [@tiptap/extension-highlight](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-highlight) from 2.22.3 to 3.3.0. - [Release notes](https://github.com/ueberdosis/tiptap/releases) - [Changelog](https://github.com/ueberdosis/tiptap/blob/develop/packages/extension-highlight/CHANGELOG.md) - [Commits](https://github.com/ueberdosis/tiptap/commits/v3.3.0/packages/extension-highlight) --- updated-dependencies: - dependency-name: "@tiptap/extension-highlight" dependency-version: 3.3.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/ui/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/package.json b/packages/ui/package.json index e943c125a..b3e487ea6 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -39,7 +39,7 @@ "@tiptap/extension-bold": "2.14.0", "@tiptap/extension-character-count": "2.14.0", "@tiptap/extension-code-block-lowlight": "2.14.0", - "@tiptap/extension-highlight": "2.22.3", + "@tiptap/extension-highlight": "3.3.0", "@tiptap/extension-image": "2.14.0", "@tiptap/extension-link": "2.14.0", "@tiptap/extension-placeholder": "2.14.0", From ee901ab8fdd0c6c4ff344bf762cc1efabe1451af Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 28 Aug 2025 11:23:24 -0400 Subject: [PATCH 08/16] chore(deps): bump dub from 0.63.7 to 0.66.1 (#1399) Bumps [dub](https://github.com/dubinc/dub-ts) from 0.63.7 to 0.66.1. - [Release notes](https://github.com/dubinc/dub-ts/releases) - [Changelog](https://github.com/dubinc/dub-ts/blob/main/RELEASES.md) - [Commits](https://github.com/dubinc/dub-ts/compare/v0.63.7...v0.66.1) --- updated-dependencies: - dependency-name: dub dependency-version: 0.66.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Mariano Fuentes --- apps/app/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/package.json b/apps/app/package.json index 0f9e14891..983cf85f9 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -57,7 +57,7 @@ "better-auth": "^1.2.8", "canvas-confetti": "^1.9.3", "d3": "^7.9.0", - "dub": "^0.63.6", + "dub": "^0.66.1", "framer-motion": "^12.18.1", "geist": "^1.3.1", "lucide-react": "^0.534.0", From 0740452faa8f3d51b38176cf69deba6f0c6f3b18 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 28 Aug 2025 11:24:52 -0400 Subject: [PATCH 09/16] chore(deps): bump @dub/embed-react from 0.0.15 to 0.0.16 (#1337) Bumps [@dub/embed-react](https://github.com/dubinc/dub) from 0.0.15 to 0.0.16. - [Commits](https://github.com/dubinc/dub/commits) --- updated-dependencies: - dependency-name: "@dub/embed-react" dependency-version: 0.0.16 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Mariano Fuentes --- apps/app/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/package.json b/apps/app/package.json index 983cf85f9..79c6b02ff 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -22,7 +22,7 @@ "@dnd-kit/utilities": "^3.2.2", "@dub/analytics": "^0.0.27", "@dub/better-auth": "^0.0.3", - "@dub/embed-react": "^0.0.15", + "@dub/embed-react": "^0.0.16", "@hookform/resolvers": "^5.1.1", "@mendable/firecrawl-js": "^1.24.0", "@nangohq/frontend": "^0.53.2", From dd928ad8ed3ef6401ec1e7d0d13772d580d987e3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 28 Aug 2025 11:41:49 -0400 Subject: [PATCH 10/16] chore: update Header component and enhance NoAccess page layout (#1425) - Added Header component to NoAccess page for better organization context. - Adjusted layout of NoAccess page to improve user experience and visual structure. - Updated Header component to conditionally render AssistantButton based on hideChat prop. Dependencies updated in bun.lock for @dub/embed-react, dub, and @tiptap/extension-highlight. Co-authored-by: Mariano Fuentes --- apps/app/src/app/(app)/no-access/page.tsx | 32 +++++++++++++---------- apps/app/src/components/header.tsx | 10 +++++-- bun.lock | 24 ++++++++--------- 3 files changed, 38 insertions(+), 28 deletions(-) diff --git a/apps/app/src/app/(app)/no-access/page.tsx b/apps/app/src/app/(app)/no-access/page.tsx index 392b34cb7..1369d4706 100644 --- a/apps/app/src/app/(app)/no-access/page.tsx +++ b/apps/app/src/app/(app)/no-access/page.tsx @@ -1,3 +1,4 @@ +import { Header } from '@/components/header'; import { OrganizationSwitcher } from '@/components/organization-switcher'; import { auth } from '@/utils/auth'; import { db } from '@db'; @@ -31,20 +32,23 @@ export default async function NoAccess() { }); return ( -
-

Access Denied

-
-

- Employees don't have access to app.trycomp.ai, did you mean to go to{' '} - - portal.trycomp.ai - - ? -

-

Please select another organization or contact your organization administrator.

-
-
- +
+
+
+

Access Denied

+
+

+ Employees don't have access to app.trycomp.ai, did you mean to go to{' '} + + portal.trycomp.ai + + ? +

+

Please select another organization or contact your organization administrator.

+
+
+ +
); diff --git a/apps/app/src/components/header.tsx b/apps/app/src/components/header.tsx index 004266a07..65e4d9185 100644 --- a/apps/app/src/components/header.tsx +++ b/apps/app/src/components/header.tsx @@ -5,14 +5,20 @@ import { Suspense } from 'react'; import { AssistantButton } from './ai/chat-button'; import { MobileMenu } from './mobile-menu'; -export async function Header({ organizationId }: { organizationId?: string }) { +export async function Header({ + organizationId, + hideChat = false, +}: { + organizationId?: string; + hideChat?: boolean; +}) { const { organizations } = await getOrganizations(); return (
- + {!hideChat && }
}> diff --git a/bun.lock b/bun.lock index 0bfebbbc4..a5cb1cf90 100644 --- a/bun.lock +++ b/bun.lock @@ -125,7 +125,7 @@ "@dnd-kit/utilities": "^3.2.2", "@dub/analytics": "^0.0.27", "@dub/better-auth": "^0.0.3", - "@dub/embed-react": "^0.0.15", + "@dub/embed-react": "^0.0.16", "@hookform/resolvers": "^5.1.1", "@mendable/firecrawl-js": "^1.24.0", "@nangohq/frontend": "^0.53.2", @@ -160,7 +160,7 @@ "better-auth": "^1.2.8", "canvas-confetti": "^1.9.3", "d3": "^7.9.0", - "dub": "^0.63.6", + "dub": "^0.66.1", "framer-motion": "^12.18.1", "geist": "^1.3.1", "lucide-react": "^0.534.0", @@ -383,7 +383,7 @@ "@tiptap/extension-bold": "2.14.0", "@tiptap/extension-character-count": "2.14.0", "@tiptap/extension-code-block-lowlight": "2.14.0", - "@tiptap/extension-highlight": "2.22.3", + "@tiptap/extension-highlight": "3.3.0", "@tiptap/extension-image": "2.14.0", "@tiptap/extension-link": "2.14.0", "@tiptap/extension-placeholder": "2.14.0", @@ -767,9 +767,9 @@ "@dub/better-auth": ["@dub/better-auth@0.0.3", "", { "dependencies": { "zod": "^3.24.4" } }, "sha512-5haJGPt8Xab1L4De6naqEwC8k2KJrxI2iAfs5t9u6iNob3DNBTsbSZGb2godIigg9ZsS2J+joKKG5hDK6jT0UQ=="], - "@dub/embed-core": ["@dub/embed-core@0.0.15", "", { "dependencies": { "@floating-ui/dom": "^1.6.12" } }, "sha512-0hfWesBfUBpzw29FbCEUSxakNCCebTcyoVXHanE45HSj0Atmx3ANpilpa25lR5g62MwLg9kcX4dR6jpTfLeQ8g=="], + "@dub/embed-core": ["@dub/embed-core@0.0.16", "", { "dependencies": { "@floating-ui/dom": "^1.6.12" } }, "sha512-CsPVxvC2E8OcgwVUA5zIwy+x+XGP4IaE4LLlMz4GoK24ACn6Hcy7Pn/2vb4HFMSPay8fzd47cEQk/2MXsFQXPQ=="], - "@dub/embed-react": ["@dub/embed-react@0.0.15", "", { "dependencies": { "@dub/embed-core": "^0.0.15", "class-variance-authority": "^0.7.0", "vite": "5.2.9" }, "peerDependencies": { "react": "^18.2.0", "react-dom": "^18.2.0" } }, "sha512-GLLAMGpn6WLXbM8q4n0lPOrjsYkmI0W3LvAAMFbb+FeQ6OwFvz390xwWW2ygfUo74wvWLSYVEOoHymiuoSrb7Q=="], + "@dub/embed-react": ["@dub/embed-react@0.0.16", "", { "dependencies": { "@dub/embed-core": "^0.0.16", "class-variance-authority": "^0.7.0", "vite": "5.2.9" }, "peerDependencies": { "react": "^18.2.0", "react-dom": "^18.2.0" } }, "sha512-HVo20cEKEX5nRxJsUq6XiUsv2HnmZec2iQbGruWf+Z//OyiUboquaGqOUnNNOmLPdmWONzaYxXYhvAmEjKU1XQ=="], "@effect/platform": ["@effect/platform@0.90.3", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.33.0", "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.17.7" } }, "sha512-XvQ37yzWQKih4Du2CYladd1i/MzqtgkTPNCaN6Ku6No4CK83hDtXIV/rP03nEoBg2R3Pqgz6gGWmE2id2G81HA=="], @@ -1707,7 +1707,7 @@ "@tiptap/extension-heading": ["@tiptap/extension-heading@2.26.1", "", { "peerDependencies": { "@tiptap/core": "^2.7.0" } }, "sha512-KSzL8WZV3pjJG9ke4RaU70+B5UlYR2S6olNt5UCAawM+fi11mobVztiBoC19xtpSVqIXC1AmXOqUgnuSvmE4ZA=="], - "@tiptap/extension-highlight": ["@tiptap/extension-highlight@2.22.3", "", { "peerDependencies": { "@tiptap/core": "^2.7.0" } }, "sha512-cdPSeQ3QcThhJdzkjK9a1871uPQjwmOf0WzTGW33lJyJDQHypWIRNUus56c3pGA7BgV9P59QW7Fm8rDnM8XkbA=="], + "@tiptap/extension-highlight": ["@tiptap/extension-highlight@3.3.0", "", { "peerDependencies": { "@tiptap/core": "^3.3.0" } }, "sha512-G+mHVXkoQ4uG97JRFN56qL42iJVKbSeWgDGssmnjNZN/W4Nsc40LuNryNbQUOM9CJbEMIT5NGAwvc/RG0OpGGQ=="], "@tiptap/extension-history": ["@tiptap/extension-history@2.26.1", "", { "peerDependencies": { "@tiptap/core": "^2.7.0", "@tiptap/pm": "^2.7.0" } }, "sha512-m6YR1gkkauIDo3PRl0gP+7Oc4n5OqDzcjVh6LvWREmZP8nmi94hfseYbqOXUb6RPHIc0JKF02eiRifT4MSd2nw=="], @@ -2755,7 +2755,7 @@ "draco3d": ["draco3d@1.5.7", "", {}, "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ=="], - "dub": ["dub@0.63.7", "", { "peerDependencies": { "@modelcontextprotocol/sdk": ">=1.5.0 <1.10.0", "zod": ">= 3" }, "optionalPeers": ["@modelcontextprotocol/sdk"], "bin": { "mcp": "bin/mcp-server.js" } }, "sha512-DhMF4ceWIPjMGA4ZU8L1pySJFtwdXRjcwchRLLWReN5t3C/MZohrLvrqbeJOBfdOE4VKGtqI8uYD3kBT+4nMSQ=="], + "dub": ["dub@0.66.1", "", { "dependencies": { "zod": "^3.20.0" } }, "sha512-a3XlK1R7Pnfj33sB7Hpcl0lidrX3Rq46ZTQN48eCoMj+2QIumWoOZsEnAIP6qfEtvGuNCaDfCzfMh0Im2zyo5g=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -4309,7 +4309,7 @@ "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], - "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], + "scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="], "schema-utils": ["schema-utils@3.3.0", "", { "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", "ajv-keywords": "^3.5.2" } }, "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg=="], @@ -5111,8 +5111,6 @@ "@react-email/components/@react-email/render": ["@react-email/render@1.1.2", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3", "react-promise-suspense": "^0.3.4" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw=="], - "@react-three/fiber/scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="], - "@react-three/postprocessing/maath": ["maath@0.6.0", "", { "peerDependencies": { "@types/three": ">=0.144.0", "three": ">=0.144.0" } }, "sha512-dSb2xQuP7vDnaYqfoKzlApeRcR2xtN8/f7WV/TMAkBC8552TwTLtOO0JTcSygkYMjNDPoo6V01jTw/aPi4JrMw=="], "@semantic-release/github/@semantic-release/error": ["@semantic-release/error@4.0.0", "", {}, "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ=="], @@ -5247,6 +5245,8 @@ "dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "dub/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "duplexer2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "ecdsa-sig-formatter/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -5821,14 +5821,14 @@ "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "react-dom/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], + "react-dropzone/file-selector": ["file-selector@2.1.2", "", { "dependencies": { "tslib": "^2.7.0" } }, "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig=="], "react-email/chalk": ["chalk@5.6.0", "", {}, "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ=="], "react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="], - "react-reconciler/scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="], - "read-cache/pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], "read-pkg/unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], From 90352385bf358b6e4d30f0345ab715652b3dc3f7 Mon Sep 17 00:00:00 2001 From: Golamrabbi Azad <35021384+golamrabbiazad@users.noreply.github.com> Date: Fri, 29 Aug 2025 02:45:21 +0600 Subject: [PATCH 11/16] fix: remove duplicate dependsOn key (#1426) Signed-off-by: Golamrabbi Azad <35021384+golamrabbiazad@users.noreply.github.com> --- turbo.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/turbo.json b/turbo.json index c9a8fd91b..7eb312055 100644 --- a/turbo.json +++ b/turbo.json @@ -64,9 +64,6 @@ "$TURBO_DEFAULT$", ".env" ], - "dependsOn": [ - "^build" - ], "outputs": [ ".next/**", "!.next/cache/**", @@ -108,4 +105,4 @@ ] } } -} \ No newline at end of file +} From abedc9ae8a523842edb7dec9f9070ceb262b9766 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 28 Aug 2025 16:46:53 -0400 Subject: [PATCH 12/16] [dev] [Marfuen] mariano/videos (#1427) * chore: update Header component and enhance NoAccess page layout - Added Header component to NoAccess page for better organization context. - Adjusted layout of NoAccess page to improve user experience and visual structure. - Updated Header component to conditionally render AssistantButton based on hideChat prop. Dependencies updated in bun.lock for @dub/embed-react, dub, and @tiptap/extension-highlight. * 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. --------- Co-authored-by: Mariano Fuentes --- apps/app/scripts/backfill-training-videos.ts | 58 +++++++++ .../admin/trigger-training-video-backfill.ts | 85 +++++++++++++ .../actions/organization/accept-invitation.ts | 6 +- .../all/actions/addEmployeeWithoutInvite.ts | 6 + .../components/EmployeeCompletionChart.tsx | 13 +- .../actions/create-organization-minimal.ts | 14 +++ .../setup/actions/create-organization.ts | 14 +++ .../README-training-video-backfill.md | 114 +++++++++++++++++ .../backfill-training-videos-for-all-orgs.ts | 69 +++++++++++ .../backfill-training-videos-for-org.ts | 115 ++++++++++++++++++ apps/app/src/lib/db/employee.ts | 11 +- 11 files changed, 495 insertions(+), 10 deletions(-) create mode 100644 apps/app/scripts/backfill-training-videos.ts create mode 100644 apps/app/src/actions/admin/trigger-training-video-backfill.ts create mode 100644 apps/app/src/jobs/tasks/onboarding/README-training-video-backfill.md create mode 100644 apps/app/src/jobs/tasks/onboarding/backfill-training-videos-for-all-orgs.ts create mode 100644 apps/app/src/jobs/tasks/onboarding/backfill-training-videos-for-org.ts diff --git a/apps/app/scripts/backfill-training-videos.ts b/apps/app/scripts/backfill-training-videos.ts new file mode 100644 index 000000000..ee1960403 --- /dev/null +++ b/apps/app/scripts/backfill-training-videos.ts @@ -0,0 +1,58 @@ +#!/usr/bin/env tsx + +/** + * Script to trigger the training video completion backfill job. + * + * Usage: + * # Backfill all organizations + * bun run scripts/backfill-training-videos.ts + * + * # Backfill specific organization + * bun run scripts/backfill-training-videos.ts --org + * + * This script is useful for: + * - Running the backfill manually + * - Testing the backfill process + * - Running on-demand backfills for specific organizations + */ + +import { backfillTrainingVideosForAllOrgs } from '@/jobs/tasks/onboarding/backfill-training-videos-for-all-orgs'; +import { backfillTrainingVideosForOrg } from '@/jobs/tasks/onboarding/backfill-training-videos-for-org'; + +async function main() { + const args = process.argv.slice(2); + const orgIndex = args.indexOf('--org'); + const organizationId = orgIndex !== -1 ? args[orgIndex + 1] : null; + + try { + if (organizationId) { + console.log(`🚀 Triggering training video backfill for organization: ${organizationId}`); + + const handle = await backfillTrainingVideosForOrg.trigger({ + organizationId: organizationId, + }); + + console.log(`✅ Successfully triggered job with ID: ${handle.id}`); + console.log(`📊 You can monitor the progress in the Trigger.dev dashboard`); + } else { + console.log('🚀 Triggering training video backfill for ALL organizations'); + + const handle = await backfillTrainingVideosForAllOrgs.trigger(); + + console.log(`✅ Successfully triggered batch job with ID: ${handle.id}`); + console.log(`📊 You can monitor the progress in the Trigger.dev dashboard`); + console.log(`⚠️ This will process ALL organizations and their members`); + } + } catch (error) { + console.error('❌ Error triggering backfill job:', error); + process.exit(1); + } +} + +// Only run if this script is executed directly +if (require.main === module) { + main().catch((error) => { + console.error('❌ Script failed:', error); + process.exit(1); + }); +} diff --git a/apps/app/src/actions/admin/trigger-training-video-backfill.ts b/apps/app/src/actions/admin/trigger-training-video-backfill.ts new file mode 100644 index 000000000..a3960131e --- /dev/null +++ b/apps/app/src/actions/admin/trigger-training-video-backfill.ts @@ -0,0 +1,85 @@ +'use server'; + +import { authActionClient } from '@/actions/safe-action'; +import { backfillTrainingVideosForAllOrgs } from '@/jobs/tasks/onboarding/backfill-training-videos-for-all-orgs'; +import { backfillTrainingVideosForOrg } from '@/jobs/tasks/onboarding/backfill-training-videos-for-org'; +import { z } from 'zod'; +import type { ActionResponse } from '../types'; + +const triggerBackfillSchema = z.object({ + organizationId: z.string().optional(), +}); + +export const triggerTrainingVideoBackfill = authActionClient + .metadata({ + name: 'trigger-training-video-backfill', + track: { + event: 'trigger_training_video_backfill', + channel: 'admin', + }, + }) + .inputSchema(triggerBackfillSchema) + .action( + async ({ + parsedInput, + ctx, + }): Promise< + ActionResponse<{ + triggered: boolean; + jobType: 'single-org' | 'all-orgs'; + organizationId?: string; + }> + > => { + try { + // Check if user has admin permissions (you may want to add additional checks) + const member = await ctx.db.member.findFirst({ + where: { + userId: ctx.user.id, + organizationId: ctx.session.activeOrganizationId, + }, + }); + + if (!member || (!member.role.includes('admin') && !member.role.includes('owner'))) { + return { + success: false, + error: 'Insufficient permissions. Admin or owner role required.', + }; + } + + if (parsedInput.organizationId) { + // Trigger backfill for a specific organization + await backfillTrainingVideosForOrg.trigger({ + organizationId: parsedInput.organizationId, + }); + + return { + success: true, + data: { + triggered: true, + jobType: 'single-org', + organizationId: parsedInput.organizationId, + }, + }; + } else { + // Trigger backfill for all organizations + await backfillTrainingVideosForAllOrgs.trigger(); + + return { + success: true, + data: { + triggered: true, + jobType: 'all-orgs', + }, + }; + } + } catch (error) { + console.error('Error triggering training video backfill:', error); + const errorMessage = + error instanceof Error ? error.message : 'Failed to trigger backfill job'; + return { + success: false, + error: errorMessage, + }; + } + }, + ); diff --git a/apps/app/src/actions/organization/accept-invitation.ts b/apps/app/src/actions/organization/accept-invitation.ts index 815375583..78e0bf7ac 100644 --- a/apps/app/src/actions/organization/accept-invitation.ts +++ b/apps/app/src/actions/organization/accept-invitation.ts @@ -1,5 +1,6 @@ 'use server'; +import { createTrainingVideoEntries } from '@/lib/db/employee'; import { db } from '@db'; import { revalidatePath, revalidateTag } from 'next/cache'; import { redirect } from 'next/navigation'; @@ -96,7 +97,7 @@ export const completeInvitation = authActionClientWithoutOrg throw new Error('Invitation role is required'); } - await db.member.create({ + const newMember = await db.member.create({ data: { userId: user.id, organizationId: invitation.organizationId, @@ -105,6 +106,9 @@ export const completeInvitation = authActionClientWithoutOrg }, }); + // Create training video completion entries for the new member + await createTrainingVideoEntries(newMember.id); + await db.invitation.update({ where: { id: invitation.id, diff --git a/apps/app/src/app/(app)/[orgId]/people/all/actions/addEmployeeWithoutInvite.ts b/apps/app/src/app/(app)/[orgId]/people/all/actions/addEmployeeWithoutInvite.ts index 4ccd429b7..db45b5648 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/actions/addEmployeeWithoutInvite.ts +++ b/apps/app/src/app/(app)/[orgId]/people/all/actions/addEmployeeWithoutInvite.ts @@ -1,5 +1,6 @@ 'use server'; +import { createTrainingVideoEntries } from '@/lib/db/employee'; import { auth } from '@/utils/auth'; import type { Role } from '@db'; import { db } from '@db'; @@ -61,6 +62,11 @@ export const addEmployeeWithoutInvite = async ({ }, }); + // Create training video completion entries for the new member + if (member?.id) { + await createTrainingVideoEntries(member.id); + } + return { success: true, data: member }; } catch (error) { console.error('Error adding employee:', error); diff --git a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx index a2c820684..166937bb0 100644 --- a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx @@ -228,10 +228,15 @@ export function EmployeeCompletionChart({

{stat.email}

- - {stat.policiesCompleted + stat.trainingsCompleted} / {stat.totalTasks}{' '} - {'tasks'} - +
+
+ {stat.policiesCompleted + stat.trainingsCompleted} / {stat.totalTasks} tasks +
+
+ {stat.policiesCompleted}/{stat.policiesTotal} policies •{' '} + {stat.trainingsCompleted}/{stat.trainingsTotal} training +
+
diff --git a/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts index afd1893af..7fae96158 100644 --- a/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts +++ b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts @@ -2,6 +2,7 @@ import { initializeOrganization } from '@/actions/organization/lib/initialize-organization'; import { authActionClientWithoutOrg } from '@/actions/safe-action'; +import { createTrainingVideoEntries } from '@/lib/db/employee'; import { auth } from '@/utils/auth'; import { db } from '@db'; import { revalidatePath } from 'next/cache'; @@ -64,6 +65,19 @@ export const createOrganizationMinimal = authActionClientWithoutOrg const orgId = newOrg.id; + // Get the member that was created with the organization (the owner) + const ownerMember = await db.member.findFirst({ + where: { + userId: session.user.id, + organizationId: orgId, + }, + }); + + // Create training video completion entries for the owner + if (ownerMember) { + await createTrainingVideoEntries(ownerMember.id); + } + // Create onboarding record for new org await db.onboarding.create({ data: { diff --git a/apps/app/src/app/(app)/setup/actions/create-organization.ts b/apps/app/src/app/(app)/setup/actions/create-organization.ts index 9509916f0..f29eee705 100644 --- a/apps/app/src/app/(app)/setup/actions/create-organization.ts +++ b/apps/app/src/app/(app)/setup/actions/create-organization.ts @@ -4,6 +4,7 @@ import { initializeOrganization } from '@/actions/organization/lib/initialize-or import { authActionClientWithoutOrg } from '@/actions/safe-action'; import { createFleetLabelForOrg } from '@/jobs/tasks/device/create-fleet-label-for-org'; import { onboardOrganization as onboardOrganizationTask } from '@/jobs/tasks/onboarding/onboard-organization'; +import { createTrainingVideoEntries } from '@/lib/db/employee'; import { auth } from '@/utils/auth'; import { db } from '@db'; import { tasks } from '@trigger.dev/sdk'; @@ -63,6 +64,19 @@ export const createOrganization = authActionClientWithoutOrg const orgId = newOrg.id; + // Get the member that was created with the organization (the owner) + const ownerMember = await db.member.findFirst({ + where: { + userId: session.user.id, + organizationId: orgId, + }, + }); + + // Create training video completion entries for the owner + if (ownerMember) { + await createTrainingVideoEntries(ownerMember.id); + } + // Create onboarding record for new org await db.onboarding.create({ data: { diff --git a/apps/app/src/jobs/tasks/onboarding/README-training-video-backfill.md b/apps/app/src/jobs/tasks/onboarding/README-training-video-backfill.md new file mode 100644 index 000000000..fede656a8 --- /dev/null +++ b/apps/app/src/jobs/tasks/onboarding/README-training-video-backfill.md @@ -0,0 +1,114 @@ +# Training Video Completion Backfill Jobs + +This directory contains Trigger.dev jobs to backfill training video completion records for existing organizations and members. + +## Overview + +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. + +## Jobs + +### 1. `backfill-training-videos-for-all-orgs` + +- **Purpose**: Processes all organizations in the system +- **Trigger ID**: `backfill-training-videos-for-all-orgs` +- **Behavior**: + - Finds all organizations + - Creates batch jobs for each organization + - Uses `batchTrigger` to process organizations in parallel + +### 2. `backfill-training-videos-for-org` + +- **Purpose**: Processes a single organization +- **Trigger ID**: `backfill-training-videos-for-org` +- **Payload**: `{ organizationId: string }` +- **Behavior**: + - Finds all members in the organization + - Creates `EmployeeTrainingVideoCompletion` records for each member + - Uses `skipDuplicates: true` to prevent duplicate records + - Processes each member individually with error handling + +## Duplicate Prevention + +Both jobs use `skipDuplicates: true` when creating records, which means: + +- ✅ Safe to run multiple times +- ✅ Won't create duplicate records +- ✅ Only creates missing records + +## Usage + +### Option 1: Via Script (Recommended for testing) + +```bash +# Backfill all organizations +bun run scripts/backfill-training-videos.ts + +# Backfill specific organization +bun run scripts/backfill-training-videos.ts --org org_123456789 +``` + +### Option 2: Via Server Action (For admin UI) + +```typescript +import { triggerTrainingVideoBackfill } from '@/actions/admin/trigger-training-video-backfill'; + +// Backfill all organizations +await triggerTrainingVideoBackfill({ organizationId: undefined }); + +// Backfill specific organization +await triggerTrainingVideoBackfill({ organizationId: 'org_123456789' }); +``` + +### Option 3: Direct Trigger.dev API + +```typescript +import { backfillTrainingVideosForAllOrgs } from '@/jobs/tasks/onboarding/backfill-training-videos-for-all-orgs'; + +await backfillTrainingVideosForAllOrgs.trigger(); +``` + +## Monitoring + +- Monitor job progress in the Trigger.dev dashboard +- Each job provides detailed logging including: + - Number of organizations processed + - Number of members processed per organization + - Number of records created + - Error details for any failures + +## Expected Results + +After running the backfill: + +- All existing members will have `EmployeeTrainingVideoCompletion` records +- Records will have `completedAt: null` (indicating not yet completed) +- Employee progress charts will show accurate data +- Training video tracking will work correctly for all members + +## Safety Features + +- **Idempotent**: Safe to run multiple times +- **Error Isolation**: Failure processing one member doesn't stop others +- **Comprehensive Logging**: Full audit trail of what was processed +- **Permission Checks**: Admin/owner permissions required for triggers +- **Batch Processing**: Efficient processing of large datasets + +## Database Impact + +- Creates records in `EmployeeTrainingVideoCompletion` table +- Number of records = (Number of members) × (Number of training videos) +- Current training videos: 5 (sat-1 through sat-5) +- Uses database transactions for consistency + +## Rollback + +If you need to remove the backfilled records: + +```sql +-- Remove all training video completion records with null completedAt +DELETE FROM "EmployeeTrainingVideoCompletion" +WHERE "completedAt" IS NULL; +``` + +⚠️ **Warning**: Only run this if you're sure you want to remove ALL incomplete training records. diff --git a/apps/app/src/jobs/tasks/onboarding/backfill-training-videos-for-all-orgs.ts b/apps/app/src/jobs/tasks/onboarding/backfill-training-videos-for-all-orgs.ts new file mode 100644 index 000000000..d2aee9533 --- /dev/null +++ b/apps/app/src/jobs/tasks/onboarding/backfill-training-videos-for-all-orgs.ts @@ -0,0 +1,69 @@ +import { db } from '@db'; +import { logger, task } from '@trigger.dev/sdk'; +import { backfillTrainingVideosForOrg } from './backfill-training-videos-for-org'; + +export const backfillTrainingVideosForAllOrgs = task({ + id: 'backfill-training-videos-for-all-orgs', + run: async () => { + logger.info('Starting training video completion backfill for all organizations'); + + try { + // Get all organizations + const organizations = await db.organization.findMany({ + select: { + id: true, + name: true, + _count: { + select: { + members: true, + }, + }, + }, + orderBy: { + createdAt: 'asc', // Process older organizations first + }, + }); + + logger.info(`Found ${organizations.length} organizations to process`); + + if (organizations.length === 0) { + logger.info('No organizations found, nothing to backfill'); + return { + success: true, + organizationsProcessed: 0, + }; + } + + // Log some stats about what we're about to process + const totalMembers = organizations.reduce((sum, org) => sum + org._count.members, 0); + logger.info( + `About to process ${organizations.length} organizations with a total of ${totalMembers} members`, + ); + + // Create batch items for processing + const batchItems = organizations.map((organization) => ({ + payload: { + organizationId: organization.id, + }, + })); + + logger.info(`Triggering batch job for ${batchItems.length} organizations`); + + // Trigger the batch job to process all organizations + await backfillTrainingVideosForOrg.batchTrigger(batchItems); + + logger.info( + `Successfully triggered training video backfill jobs for ${organizations.length} organizations`, + ); + + return { + success: true, + organizationsProcessed: organizations.length, + totalMembers, + }; + } catch (error) { + logger.error(`Error during training video backfill batch trigger: ${error}`); + throw error; + } + }, +}); diff --git a/apps/app/src/jobs/tasks/onboarding/backfill-training-videos-for-org.ts b/apps/app/src/jobs/tasks/onboarding/backfill-training-videos-for-org.ts new file mode 100644 index 000000000..21828c0ea --- /dev/null +++ b/apps/app/src/jobs/tasks/onboarding/backfill-training-videos-for-org.ts @@ -0,0 +1,115 @@ +import { trainingVideos } from '@/lib/data/training-videos'; +import { db } from '@db'; +import { logger, task } from '@trigger.dev/sdk'; + +export const backfillTrainingVideosForOrg = task({ + id: 'backfill-training-videos-for-org', + retry: { + maxAttempts: 3, + }, + run: async (payload: { organizationId: string }) => { + logger.info(`Starting training video backfill for organization ${payload.organizationId}`); + + try { + // Get all members for this organization + const members = await db.member.findMany({ + where: { + organizationId: payload.organizationId, + }, + select: { + id: true, + user: { + select: { + email: true, + }, + }, + }, + }); + + logger.info(`Found ${members.length} members in organization ${payload.organizationId}`); + + if (members.length === 0) { + logger.info(`No members found for organization ${payload.organizationId}, skipping`); + return { + success: true, + organizationId: payload.organizationId, + membersProcessed: 0, + recordsCreated: 0, + }; + } + + let totalRecordsCreated = 0; + let membersProcessed = 0; + let membersSkipped = 0; + + // Process each member + for (const member of members) { + try { + logger.info(`Processing member ${member.id} (${member.user.email})`); + + // Check if this member already has any training video completion records + // (including old video IDs like sat-1, sat-2, etc.) + const existingRecords = await db.employeeTrainingVideoCompletion.findMany({ + where: { + memberId: member.id, + }, + select: { + videoId: true, + }, + }); + + if (existingRecords.length > 0) { + const videoIds = existingRecords.map((r) => r.videoId).join(', '); + logger.info( + `Member ${member.id} already has ${existingRecords.length} training video completion records (${videoIds}), skipping`, + ); + membersSkipped++; + continue; + } + + // Create training video completion entries for this member + // Using skipDuplicates to prevent duplicate records + const result = await db.employeeTrainingVideoCompletion.createMany({ + data: trainingVideos.map((video) => ({ + memberId: member.id, + videoId: video.id, + })), + skipDuplicates: true, + }); + + totalRecordsCreated += result.count; + membersProcessed++; + + logger.info( + `Created ${result.count} training video completion records for member ${member.id}`, + ); + } catch (memberError) { + logger.error( + `Failed to process member ${member.id} (${member.user.email}): ${memberError}`, + ); + // Continue processing other members even if one fails + } + } + + logger.info( + `Completed training video backfill for organization ${payload.organizationId}. ` + + `Total members: ${members.length}, New records created: ${membersProcessed}, ` + + `Skipped (already had records): ${membersSkipped}, Records created: ${totalRecordsCreated}`, + ); + + return { + success: true, + organizationId: payload.organizationId, + membersProcessed, + membersSkipped, + totalMembers: members.length, + recordsCreated: totalRecordsCreated, + }; + } catch (error) { + logger.error( + `Error during training video backfill for organization ${payload.organizationId}: ${error}`, + ); + throw error; + } + }, +}); diff --git a/apps/app/src/lib/db/employee.ts b/apps/app/src/lib/db/employee.ts index 5a0abd7f5..1fd2971b6 100644 --- a/apps/app/src/lib/db/employee.ts +++ b/apps/app/src/lib/db/employee.ts @@ -100,21 +100,22 @@ async function inviteEmployeeToPortal({ } /** - * Creates training video tracking entries for a new employee + * Creates training video tracking entries for a new member + * This function is exported so it can be used in other invitation flows */ -async function createTrainingVideoEntries(employeeId: string) { - console.log(`Creating training video entries for employee ${employeeId}`); +export async function createTrainingVideoEntries(memberId: string) { + console.log(`Creating training video entries for member ${memberId}`); // Create an entry for each video in the system const result = await db.employeeTrainingVideoCompletion.createMany({ data: trainingVideos.map((video) => ({ - memberId: employeeId, + memberId: memberId, videoId: video.id, })), skipDuplicates: true, }); - console.log(`Created ${result.count} training video entries for employee ${employeeId}`); + console.log(`Created ${result.count} training video entries for member ${memberId}`); return result; } From 1a3b08ddd4819e513b02fad4ee62b7d34d745ab6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 28 Aug 2025 17:01:36 -0400 Subject: [PATCH 13/16] [dev] [Marfuen] mariano/fix-bug (#1429) * chore: update Header component and enhance NoAccess page layout - Added Header component to NoAccess page for better organization context. - Adjusted layout of NoAccess page to improve user experience and visual structure. - Updated Header component to conditionally render AssistantButton based on hideChat prop. Dependencies updated in bun.lock for @dub/embed-react, dub, and @tiptap/extension-highlight. * 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. * fix: update database access in training video backfill action - Replaced direct database access reference with a centralized db import for improved consistency and maintainability in the triggerTrainingVideoBackfill action. * chore: remove backfill training videos script - Deleted the backfill-training-videos.ts script as it is no longer needed for triggering training video completion backfill jobs. This change simplifies the codebase and removes redundant functionality. * chore: remove training video backfill action and documentation - Deleted the trigger-training-video-backfill action and its associated README documentation as they are no longer needed. This change streamlines the codebase and eliminates outdated references to backfill functionality. --------- Co-authored-by: Mariano Fuentes From a71740337c35485260eced3ef26aafec407c7111 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 28 Aug 2025 17:06:49 -0400 Subject: [PATCH 14/16] Mariano/updated employees (#1430) * chore: update Header component and enhance NoAccess page layout - Added Header component to NoAccess page for better organization context. - Adjusted layout of NoAccess page to improve user experience and visual structure. - Updated Header component to conditionally render AssistantButton based on hideChat prop. Dependencies updated in bun.lock for @dub/embed-react, dub, and @tiptap/extension-highlight. * 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. * fix: update database access in training video backfill action - Replaced direct database access reference with a centralized db import for improved consistency and maintainability in the triggerTrainingVideoBackfill action. * chore: remove backfill training videos script - Deleted the backfill-training-videos.ts script as it is no longer needed for triggering training video completion backfill jobs. This change simplifies the codebase and removes redundant functionality. * chore: remove training video backfill action and documentation - Deleted the trigger-training-video-backfill action and its associated README documentation as they are no longer needed. This change streamlines the codebase and eliminates outdated references to backfill functionality. From 98c6503611234a5ef283bc141eb6132260f8a8e0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 28 Aug 2025 17:13:56 -0400 Subject: [PATCH 15/16] chore: remove training video backfill action and related documentation (#1431) - Deleted the trigger-training-video-backfill action and its associated README documentation as they are no longer needed. This change streamlines the codebase and eliminates outdated references to backfill functionality. Co-authored-by: Mariano Fuentes --- .../admin/trigger-training-video-backfill.ts | 85 ------------- .../README-training-video-backfill.md | 114 ------------------ 2 files changed, 199 deletions(-) delete mode 100644 apps/app/src/actions/admin/trigger-training-video-backfill.ts delete mode 100644 apps/app/src/jobs/tasks/onboarding/README-training-video-backfill.md diff --git a/apps/app/src/actions/admin/trigger-training-video-backfill.ts b/apps/app/src/actions/admin/trigger-training-video-backfill.ts deleted file mode 100644 index a3960131e..000000000 --- a/apps/app/src/actions/admin/trigger-training-video-backfill.ts +++ /dev/null @@ -1,85 +0,0 @@ -'use server'; - -import { authActionClient } from '@/actions/safe-action'; -import { backfillTrainingVideosForAllOrgs } from '@/jobs/tasks/onboarding/backfill-training-videos-for-all-orgs'; -import { backfillTrainingVideosForOrg } from '@/jobs/tasks/onboarding/backfill-training-videos-for-org'; -import { z } from 'zod'; -import type { ActionResponse } from '../types'; - -const triggerBackfillSchema = z.object({ - organizationId: z.string().optional(), -}); - -export const triggerTrainingVideoBackfill = authActionClient - .metadata({ - name: 'trigger-training-video-backfill', - track: { - event: 'trigger_training_video_backfill', - channel: 'admin', - }, - }) - .inputSchema(triggerBackfillSchema) - .action( - async ({ - parsedInput, - ctx, - }): Promise< - ActionResponse<{ - triggered: boolean; - jobType: 'single-org' | 'all-orgs'; - organizationId?: string; - }> - > => { - try { - // Check if user has admin permissions (you may want to add additional checks) - const member = await ctx.db.member.findFirst({ - where: { - userId: ctx.user.id, - organizationId: ctx.session.activeOrganizationId, - }, - }); - - if (!member || (!member.role.includes('admin') && !member.role.includes('owner'))) { - return { - success: false, - error: 'Insufficient permissions. Admin or owner role required.', - }; - } - - if (parsedInput.organizationId) { - // Trigger backfill for a specific organization - await backfillTrainingVideosForOrg.trigger({ - organizationId: parsedInput.organizationId, - }); - - return { - success: true, - data: { - triggered: true, - jobType: 'single-org', - organizationId: parsedInput.organizationId, - }, - }; - } else { - // Trigger backfill for all organizations - await backfillTrainingVideosForAllOrgs.trigger(); - - return { - success: true, - data: { - triggered: true, - jobType: 'all-orgs', - }, - }; - } - } catch (error) { - console.error('Error triggering training video backfill:', error); - const errorMessage = - error instanceof Error ? error.message : 'Failed to trigger backfill job'; - return { - success: false, - error: errorMessage, - }; - } - }, - ); diff --git a/apps/app/src/jobs/tasks/onboarding/README-training-video-backfill.md b/apps/app/src/jobs/tasks/onboarding/README-training-video-backfill.md deleted file mode 100644 index fede656a8..000000000 --- a/apps/app/src/jobs/tasks/onboarding/README-training-video-backfill.md +++ /dev/null @@ -1,114 +0,0 @@ -# Training Video Completion Backfill Jobs - -This directory contains Trigger.dev jobs to backfill training video completion records for existing organizations and members. - -## Overview - -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. - -## Jobs - -### 1. `backfill-training-videos-for-all-orgs` - -- **Purpose**: Processes all organizations in the system -- **Trigger ID**: `backfill-training-videos-for-all-orgs` -- **Behavior**: - - Finds all organizations - - Creates batch jobs for each organization - - Uses `batchTrigger` to process organizations in parallel - -### 2. `backfill-training-videos-for-org` - -- **Purpose**: Processes a single organization -- **Trigger ID**: `backfill-training-videos-for-org` -- **Payload**: `{ organizationId: string }` -- **Behavior**: - - Finds all members in the organization - - Creates `EmployeeTrainingVideoCompletion` records for each member - - Uses `skipDuplicates: true` to prevent duplicate records - - Processes each member individually with error handling - -## Duplicate Prevention - -Both jobs use `skipDuplicates: true` when creating records, which means: - -- ✅ Safe to run multiple times -- ✅ Won't create duplicate records -- ✅ Only creates missing records - -## Usage - -### Option 1: Via Script (Recommended for testing) - -```bash -# Backfill all organizations -bun run scripts/backfill-training-videos.ts - -# Backfill specific organization -bun run scripts/backfill-training-videos.ts --org org_123456789 -``` - -### Option 2: Via Server Action (For admin UI) - -```typescript -import { triggerTrainingVideoBackfill } from '@/actions/admin/trigger-training-video-backfill'; - -// Backfill all organizations -await triggerTrainingVideoBackfill({ organizationId: undefined }); - -// Backfill specific organization -await triggerTrainingVideoBackfill({ organizationId: 'org_123456789' }); -``` - -### Option 3: Direct Trigger.dev API - -```typescript -import { backfillTrainingVideosForAllOrgs } from '@/jobs/tasks/onboarding/backfill-training-videos-for-all-orgs'; - -await backfillTrainingVideosForAllOrgs.trigger(); -``` - -## Monitoring - -- Monitor job progress in the Trigger.dev dashboard -- Each job provides detailed logging including: - - Number of organizations processed - - Number of members processed per organization - - Number of records created - - Error details for any failures - -## Expected Results - -After running the backfill: - -- All existing members will have `EmployeeTrainingVideoCompletion` records -- Records will have `completedAt: null` (indicating not yet completed) -- Employee progress charts will show accurate data -- Training video tracking will work correctly for all members - -## Safety Features - -- **Idempotent**: Safe to run multiple times -- **Error Isolation**: Failure processing one member doesn't stop others -- **Comprehensive Logging**: Full audit trail of what was processed -- **Permission Checks**: Admin/owner permissions required for triggers -- **Batch Processing**: Efficient processing of large datasets - -## Database Impact - -- Creates records in `EmployeeTrainingVideoCompletion` table -- Number of records = (Number of members) × (Number of training videos) -- Current training videos: 5 (sat-1 through sat-5) -- Uses database transactions for consistency - -## Rollback - -If you need to remove the backfilled records: - -```sql --- Remove all training video completion records with null completedAt -DELETE FROM "EmployeeTrainingVideoCompletion" -WHERE "completedAt" IS NULL; -``` - -⚠️ **Warning**: Only run this if you're sure you want to remove ALL incomplete training records. From 2691ce8ee73c2c273669cf8ed1bd1cf2535eee03 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 28 Aug 2025 17:26:48 -0400 Subject: [PATCH 16/16] [dev] [Marfuen] mariano/batch (#1432) * chore: remove training video backfill action and related documentation - Deleted the trigger-training-video-backfill action and its associated README documentation as they are no longer needed. This change streamlines the codebase and eliminates outdated references to backfill functionality. * feat: enhance training video backfill process with batch processing - Updated the backfillTrainingVideosForAllOrgs function to process organizations in batches of 500, improving efficiency and handling of large datasets. - Added logging for batch processing to track the number of organizations processed and any errors encountered during triggering. - Adjusted return values to reflect the total number of organizations processed and the number of batches created. --------- Co-authored-by: Mariano Fuentes --- .../backfill-training-videos-for-all-orgs.ts | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/app/src/jobs/tasks/onboarding/backfill-training-videos-for-all-orgs.ts b/apps/app/src/jobs/tasks/onboarding/backfill-training-videos-for-all-orgs.ts index d2aee9533..d76a19834 100644 --- a/apps/app/src/jobs/tasks/onboarding/backfill-training-videos-for-all-orgs.ts +++ b/apps/app/src/jobs/tasks/onboarding/backfill-training-videos-for-all-orgs.ts @@ -41,24 +41,50 @@ export const backfillTrainingVideosForAllOrgs = task({ ); // Create batch items for processing - const batchItems = organizations.map((organization) => ({ + const allBatchItems = organizations.map((organization) => ({ payload: { organizationId: organization.id, }, })); - logger.info(`Triggering batch job for ${batchItems.length} organizations`); + // Split into chunks of 500 (Trigger.dev batch size limit) + const BATCH_SIZE = 500; + const batches: (typeof allBatchItems)[] = []; - // Trigger the batch job to process all organizations - await backfillTrainingVideosForOrg.batchTrigger(batchItems); + for (let i = 0; i < allBatchItems.length; i += BATCH_SIZE) { + batches.push(allBatchItems.slice(i, i + BATCH_SIZE)); + } + + logger.info( + `Splitting ${allBatchItems.length} organizations into ${batches.length} batches of max ${BATCH_SIZE} each`, + ); + + // Process each batch + let totalTriggered = 0; + for (let i = 0; i < batches.length; i++) { + const batch = batches[i]; + logger.info( + `Triggering batch ${i + 1}/${batches.length} with ${batch.length} organizations`, + ); + + try { + await backfillTrainingVideosForOrg.batchTrigger(batch); + totalTriggered += batch.length; + logger.info(`Successfully triggered batch ${i + 1}/${batches.length}`); + } catch (error) { + logger.error(`Failed to trigger batch ${i + 1}/${batches.length}: ${error}`); + throw error; + } + } logger.info( - `Successfully triggered training video backfill jobs for ${organizations.length} organizations`, + `Successfully triggered training video backfill jobs for ${totalTriggered} organizations across ${batches.length} batches`, ); return { success: true, - organizationsProcessed: organizations.length, + organizationsProcessed: totalTriggered, + totalBatches: batches.length, totalMembers, }; } catch (error) {