← Terug naar blog
AI Engineering
11 november 2025

Wervingscampagnes met een uitbreidbaar scoringssysteem

Hoe je medewerkers prioriteert voor uitnodigingen op basis van gewogen factoren — en waarom je dat generiek bouwt in plaats van hardcoded
Aron Heesakkers
Aron HeesakkersFullstack Developer & AI Engineer

Een campagne om flexkrachten te werven voor een evenement heeft een budget: je kunt 50 medewerkers uitnodigen. Maar je hebt 300 beschikbare kandidaten. Welke 50 nodig je als eerste uit?

De naïeve aanpak: willekeurig, of op volgorde van aanmeldingsdatum. De betere aanpak: scoren op basis van relevante factoren, en de hoogst scorenden eerst uitnodigen.

Het probleem: "relevant" verschilt per organisatie en per campagne. Een horecabedrijf wil wellicht ervaren medewerkers prioriteren; een evenementenbureau wil leeftijdsdiversiteit. Een hardcoded scoringslogica past nooit overal.

De architectuur

Een campagne heeft rondes. Elke ronde nodigt een batch kandidaten uit tot het invitatie-budget bereikt is:

model Campaign {
  id              String           @id
  maxInvitations  Int              // totaal uitnodigingsbudget
  scoringFactors  Json             // array van CampaignScoringFactor
  rounds          CampaignRound[]
}

model CampaignRound {
  id         String   @id
  campaignId String
  roundNumber Int
  invitations CampaignInvitation[]
}

Bij aanmaken wordt één ronde aangemaakt. Nieuwe rondes worden dynamisch aangemaakt als een vorige ronde niet genoeg kandidaten oplevert die accepteren. Dat stelt campagnes in staat om door te gaan tot het budget gevuld is, zonder vooraf te weten hoeveel afwijzingen er zullen zijn.

Scoringsfactoren

Een CampaignScoringFactor koppelt een factortype aan een gewicht:

export class CampaignScoringFactorDto {
  @IsEnum(CampaignScoringFactorType)
  type: CampaignScoringFactorType

  @IsNumber()
  @Min(0)
  @Max(1)
  weight: number
}

export enum CampaignScoringFactorType {
  AGE = 'AGE',
  // uitbreidbaar: EXPERIENCE = 'EXPERIENCE', DISTANCE = 'DISTANCE', ...
}

Het gewicht loopt van 0 tot 1. Een gewicht van 0 schakelt de factor uit; een gewicht van 1 geeft de factor maximale invloed.

Meerdere factoren kunnen tegelijkertijd actief zijn. De totaalscore is een gewogen som: score = factor1.normalizedValue * factor1.weight + factor2.normalizedValue * factor2.weight. Normaliseren zorgt dat factoren met verschillende schalen (leeftijd 0–65, afstand 0–50 km) eerlijk bijdragen.

Scoringsengine

De CampaignScoringService berekent voor elke kandidaat een score op basis van de geconfigureerde factoren:

@Injectable()
export class CampaignScoringService {
  scoreCandidate(
    candidate: CandidateProfile,
    factors: CampaignScoringFactor[],
  ): number {
    if (factors.length === 0) return 0

    return factors.reduce((total, factor) => {
      const rawScore = this.computeFactor(candidate, factor.type)
      return total + rawScore * factor.weight
    }, 0)
  }

  private computeFactor(
    candidate: CandidateProfile,
    type: CampaignScoringFactorType,
  ): number {
    switch (type) {
      case CampaignScoringFactorType.AGE:
        // Normaliseer: 0 = 16 jaar, 1 = 65 jaar
        return Math.min(Math.max((candidate.age - 16) / (65 - 16), 0), 1)

      default:
        return 0
    }
  }
}

De switch-statement is de uitbreidingspunt: een nieuwe factortype voegt een case toe en een bijbehorende normalisatiefunctie. De rest van de architectuur (campagneconfiguratie, rondeaanmaak, invitatiebeheer) hoeft niet te wijzigen.

Kandidaten selecteren per ronde

De selectCandidatesForRound-service combineert scoring met beschikbaarheidsfiltering:

async selectCandidatesForRound(
  campaign: Campaign,
  round: CampaignRound,
  alreadyInvited: Set<string>,
): Promise<CandidateSelection[]> {
  // Haal beschikbare kandidaten op (niet al uitgenodigd, voldoet aan criteria)
  const pool = await this.candidateRepository.findAvailable({
    organizationId: campaign.organizationId,
    excludeIds: alreadyInvited,
    targetSlots: campaign.targetSlots,
  })

  // Scoor en sorteer
  const scored = pool.map((candidate) => ({
    candidate,
    score: this.scoringService.scoreCandidate(candidate, campaign.scoringFactors),
  }))
  scored.sort((a, b) => b.score - a.score)

  // Neem de top N op basis van het resterende budget
  const remainingBudget = campaign.maxInvitations - alreadyInvited.size
  return scored.slice(0, remainingBudget).map(({ candidate, score }) => ({
    candidateId: candidate.id,
    roundId: round.id,
    score,
  }))
}

alreadyInvited is een Set<string> met alle kandidaat-IDs die al een uitnodiging hebben ontvangen in voorgaande rondes. Dat voorkomt dubbele uitnodigingen.

Ghost-accounts voor externe werving

Niet alle kandidaten hebben al een account in het systeem. Voor externe werving maakt de campagne ghost-accounts aan: tijdelijke accounts met een willekeurig token, aangemaakt op het moment van uitnodiging:

async createGhostAccount(email: string, campaignId: string): Promise<GhostUser> {
  const token = randomBytes(32).toString('hex')

  return this.db.user.create({
    data: {
      email,
      isGhost: true,
      ghostToken: token,
      ghostExpiry: addDays(new Date(), 30),  // 30 dagen geldig
      invitedByCampaignId: campaignId,
    },
  })
}

De uitnodigingslink bevat het token. Bij activering wordt het ghost-account omgezet naar een volledig account. Als het ghost-account na 30 dagen niet geactiveerd is, wordt het opgeruimd.

isGhost: true is een flag die de applicatie gebruikt om ghost-accounts uit te sluiten van normale lijstweergaves — ze verschijnen alleen in de campagne-UI als "uitgenodigd, niet geactiveerd".

Wanneer gebruik je rondes?

Rondes zijn nuttig bij campagnes waar de acceptatiegraad onzeker is. Als je 50 medewerkers nodig hebt en 60 uitnodigt, maar slechts 30 accepteren, wil je automatisch nog een ronde met 20 nieuwe uitnodigingen starten.

Rondes kun je ook gebruiken voor gefaseerde werving: eerste ronde voor vaste medewerkers, tweede ronde voor oproepkrachten, derde ronde voor nieuw te werven externe kandidaten. Elk met hun eigen scoringsfactoren en invitatie-budget.

De architectuur ondersteunt dit: elke ronde heeft zijn eigen CampaignInvitation-records, en nieuwe rondes worden aangemaakt op basis van de resterende behoefte na de vorige ronde. De campagne stopt als het totale maxInvitations-budget bereikt is of als er geen geschikte kandidaten meer zijn.


Project bespreken?

Heb je een use case waar je over twijfelt?

We praten graag over of een AI-aanpak past — en zeggen het eerlijk als het beter zonder kan. Vaste prijs, afgesproken scope.
Plan een gesprek
Probeer AI