All files / src/utils syncRoles.ts

100% Statements 41/41
84.37% Branches 27/32
100% Functions 2/2
100% Lines 41/41

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 1512x   2x                     22x       22x                     2x       15x             15x 22x   22x   22x           17x   11x 11x                   8x 8x     3x           6x     6x 1x 1x       5x 4x     4x         4x   1x       1x       3x                       2x 2x 2x       1x               5x               15x 5x   15x 2x   15x 1x   15x 6x     15x    
import crypto from 'crypto'
import type { Payload } from 'payload'
import { getRolesSlug } from './getRolesSlug'
import type { SystemRole, SyncResults } from '../types'
 
// Re-export for backward compatibility (will be removed in next major version)
export type DefaultRole = SystemRole
 
/**
 * Generate a hash from role configuration
 * Used to detect when a role's configuration has changed
 */
function generateRoleHash(permissions: string[], visibleFor?: string[]): string {
  const data = JSON.stringify({ 
    permissions: permissions.sort(),
    visibleFor: visibleFor ? visibleFor.sort() : []
  })
  return crypto
    .createHash('sha256')
    .update(data)
    .digest('hex')
    .substring(0, 16)
}
 
/**
 * Sync system-managed roles with optimistic locking
 * This ensures role configurations are up-to-date while preventing race conditions
 */
export async function syncSystemRoles(
  payload: Payload,
  defaultRoles: SystemRole[]
): Promise<SyncResults> {
  const results: SyncResults = {
    created: [],
    updated: [],
    failed: [],
    skipped: [],
  }
 
  for (const defaultRole of defaultRoles) {
    const newHash = generateRoleHash(defaultRole.permissions, defaultRole.visibleFor)
 
    try {
      // Find existing role
      const existing = await payload.find({
        collection: getRolesSlug() as 'roles',
        where: { name: { equals: defaultRole.name } },
        limit: 1,
      })
 
      if (existing.docs.length === 0) {
        // Create new role
        try {
          await payload.create({
            collection: getRolesSlug() as 'roles',
            data: {
              ...defaultRole,
              active: defaultRole.active ?? true,  // Ensure active is always boolean
              configHash: newHash,
              configVersion: 1,
              systemManaged: true,
            },
          })
          results.created.push(defaultRole.name)
          console.info(`✅ Created role: ${defaultRole.name}`)
        } catch (createError) {
          // Another instance might have created it concurrently
          results.failed.push({
            role: defaultRole.name,
            error: `Create failed: ${createError instanceof Error ? createError.message : String(createError)}`,
          })
        }
      } else {
        const role = existing.docs[0]
 
        // Skip non-system-managed roles (user-created)
        if (!role.systemManaged) {
          results.skipped.push(defaultRole.name)
          continue
        }
 
        // Check if update needed (hash mismatch)
        if (role.configHash !== newHash) {
          try {
            // Optimistic locking update
            // First check if version still matches
            const currentRole = await payload.findByID({
              collection: getRolesSlug() as 'roles',
              id: role.id,
            })
 
            if (currentRole.configVersion !== role.configVersion) {
              // Another instance already updated
              results.failed.push({
                role: defaultRole.name,
                error: 'Concurrent update detected',
              })
              continue
            }
 
            // Safe to update
            const updateResult = await payload.update({
              collection: getRolesSlug() as 'roles',
              id: role.id,
              data: {
                permissions: defaultRole.permissions,
                protected: defaultRole.protected ?? role.protected,
                visibleFor: defaultRole.visibleFor,
                configHash: newHash,
                configVersion: (role.configVersion || 0) + 1,
              },
            })
 
            Eif (updateResult) {
              results.updated.push(defaultRole.name)
              console.info(`🔄 Updated role: ${defaultRole.name}`)
            }
          } catch (updateError) {
            // Update failed - likely due to version mismatch (another instance updated)
            results.failed.push({
              role: defaultRole.name,
              error: `Update failed (likely concurrent update): ${updateError instanceof Error ? updateError.message : String(updateError)}`,
            })
          }
        }
      }
    } catch (error) {
      results.failed.push({
        role: defaultRole.name,
        error: error instanceof Error ? error.message : String(error),
      })
    }
  }
 
  // Log summary
  if (results.created.length > 0) {
    console.info('✅ Created roles:', results.created.join(', '))
  }
  if (results.updated.length > 0) {
    console.info('🔄 Updated roles:', results.updated.join(', '))
  }
  if (results.skipped.length > 0) {
    console.info('⏭️ Skipped user-created roles:', results.skipped.join(', '))
  }
  if (results.failed.length > 0) {
    console.warn('⚠️ Failed operations (likely handled by another instance):', results.failed)
  }
 
  return results
}