feat: implementation des dossiers candidats PDF, gestion des entretiens et optimisation de l'analyse IA
This commit is contained in:
131
app/Http/Controllers/Admin/CandidateExportController.php
Normal file
131
app/Http/Controllers/Admin/CandidateExportController.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Candidate;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CandidateExportController extends Controller
|
||||
{
|
||||
public function exportDossier(Candidate $candidate)
|
||||
{
|
||||
$candidate->load([
|
||||
'user',
|
||||
'jobPosition',
|
||||
'tenant',
|
||||
'attempts.quiz.questions',
|
||||
'attempts.answers.option',
|
||||
'attempts.answers.question',
|
||||
'documents'
|
||||
]);
|
||||
|
||||
$filename = 'Dossier_' . str_replace(' ', '_', $candidate->user->name) . '_' . date('Ymd') . '.pdf';
|
||||
|
||||
// 1. Generate Main Report with DomPDF
|
||||
$pdfReport = Pdf::loadView('pdfs.candidate-dossier', [
|
||||
'candidate' => $candidate
|
||||
]);
|
||||
$reportBinary = $pdfReport->output();
|
||||
|
||||
// 2. Setup FPDI for merging
|
||||
$mergedPdf = new \setasign\Fpdi\Fpdi();
|
||||
|
||||
// Add Main Report Pages
|
||||
$reportTmp = tempnam(sys_get_temp_dir(), 'pdf_report_');
|
||||
file_put_contents($reportTmp, $reportBinary);
|
||||
|
||||
try {
|
||||
$pageCount = $mergedPdf->setSourceFile($reportTmp);
|
||||
for ($pageNo = 1; $pageNo <= $pageCount; $pageNo++) {
|
||||
$templateId = $mergedPdf->importPage($pageNo);
|
||||
$size = $mergedPdf->getTemplateSize($templateId);
|
||||
$mergedPdf->AddPage($size['orientation'], [$size['width'], $size['height']]);
|
||||
$mergedPdf->useTemplate($templateId);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('FPDI Error on report: ' . $e->getMessage());
|
||||
}
|
||||
@unlink($reportTmp);
|
||||
|
||||
// 3. Append Candidate Documents (CV, Letter)
|
||||
foreach ($candidate->documents as $doc) {
|
||||
if (\Storage::disk('local')->exists($doc->file_path)) {
|
||||
$filePath = \Storage::disk('local')->path($doc->file_path);
|
||||
|
||||
try {
|
||||
$pageCount = $mergedPdf->setSourceFile($filePath);
|
||||
for ($pageNo = 1; $pageNo <= $pageCount; $pageNo++) {
|
||||
$templateId = $mergedPdf->importPage($pageNo);
|
||||
$size = $mergedPdf->getTemplateSize($templateId);
|
||||
$mergedPdf->AddPage($size['orientation'], [$size['width'], $size['height']]);
|
||||
$mergedPdf->useTemplate($templateId);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
\Log::warning('Could not merge document ID ' . $doc->id . ': ' . $e->getMessage());
|
||||
|
||||
// Add a professional placeholder page for unmergable documents
|
||||
$mergedPdf->AddPage('P', 'A4');
|
||||
$mergedPdf->SetFont('Arial', 'B', 16);
|
||||
$mergedPdf->SetTextColor(0, 79, 130); // Primary color
|
||||
$mergedPdf->Ln(50);
|
||||
$mergedPdf->Cell(0, 20, utf8_decode("DOCUMENT JOINT : " . strtoupper($doc->type)), 0, 1, 'C');
|
||||
$mergedPdf->SetFont('Arial', 'I', 12);
|
||||
$mergedPdf->Cell(0, 10, utf8_decode($doc->original_name), 0, 1, 'C');
|
||||
|
||||
$mergedPdf->Ln(30);
|
||||
$mergedPdf->SetFont('Arial', '', 11);
|
||||
$mergedPdf->SetTextColor(100, 100, 100);
|
||||
$mergedPdf->MultiCell(0, 8, utf8_decode("Ce document n'a pas pu être fusionné automatiquement au dossier car son format est trop récent (PDF 1.5+).\n\nPour garantir l'intégrité de la mise en page, veuillez consulter ce document séparément via l'interface du tableau de bord candidat."), 0, 'C');
|
||||
|
||||
$mergedPdf->Ln(20);
|
||||
$mergedPdf->SetDrawColor(224, 176, 76); // Highlight color
|
||||
$mergedPdf->Line(60, $mergedPdf->GetY(), 150, $mergedPdf->GetY());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response($mergedPdf->Output('S'), 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
'Cache-Control' => 'no-cache, no-store, must-revalidate',
|
||||
'Pragma' => 'no-cache',
|
||||
'Expires' => '0',
|
||||
]);
|
||||
}
|
||||
|
||||
public function exportZip(Candidate $candidate)
|
||||
{
|
||||
$candidate->load(['user', 'jobPosition', 'tenant', 'attempts.quiz.questions', 'attempts.answers.option', 'attempts.answers.question', 'documents']);
|
||||
|
||||
$baseName = 'Dossier_' . str_replace(' ', '_', $candidate->user->name) . '_' . date('Ymd');
|
||||
$zipPath = tempnam(sys_get_temp_dir(), 'candidate_zip_');
|
||||
$zip = new \ZipArchive();
|
||||
|
||||
if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
return back()->with('error', 'Impossible de créer le fichier ZIP.');
|
||||
}
|
||||
|
||||
// 1. Add the main report (Rapport CABM)
|
||||
$pdfReport = Pdf::loadView('pdfs.candidate-dossier', [
|
||||
'candidate' => $candidate
|
||||
]);
|
||||
$zip->addFromString($baseName . '/Rapport_Synthese_CABM.pdf', $pdfReport->output());
|
||||
|
||||
// 2. Add original documents
|
||||
foreach ($candidate->documents as $doc) {
|
||||
if (\Storage::disk('local')->exists($doc->file_path)) {
|
||||
$content = \Storage::disk('local')->get($doc->file_path);
|
||||
// Sanitize original name or use type
|
||||
$ext = pathinfo($doc->original_name, PATHINFO_EXTENSION);
|
||||
$fileName = strtoupper($doc->type) . '_' . $doc->original_name;
|
||||
$zip->addFromString($baseName . '/Documents_Originaux/' . $fileName, $content);
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
return response()->download($zipPath, $baseName . '.zip')->deleteFileAfterSend(true);
|
||||
}
|
||||
}
|
||||
@@ -90,12 +90,23 @@ class AttemptController extends Controller
|
||||
|
||||
public function saveAnswer(Request $request, Attempt $attempt)
|
||||
{
|
||||
// Security: Verify the authenticated user owns this attempt
|
||||
$candidate = auth()->user()->candidate;
|
||||
if (!$candidate || $attempt->candidate_id !== $candidate->id) {
|
||||
abort(403, 'You are not authorized to submit answers for this attempt.');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'question_id' => 'required|exists:questions,id',
|
||||
'option_id' => 'nullable|exists:options,id',
|
||||
'text_content' => 'nullable|string',
|
||||
]);
|
||||
|
||||
// Extra guard: prevent answering a finished attempt
|
||||
if ($attempt->finished_at) {
|
||||
return response()->json(['error' => 'This attempt is already finished.'], 403);
|
||||
}
|
||||
|
||||
Answer::updateOrCreate(
|
||||
[
|
||||
'attempt_id' => $attempt->id,
|
||||
@@ -112,6 +123,12 @@ class AttemptController extends Controller
|
||||
|
||||
public function finish(Attempt $attempt)
|
||||
{
|
||||
// Security: Verify the authenticated user owns this attempt
|
||||
$candidate = auth()->user()->candidate;
|
||||
if (!$candidate || $attempt->candidate_id !== $candidate->id) {
|
||||
abort(403, 'You are not authorized to finish this attempt.');
|
||||
}
|
||||
|
||||
if ($attempt->finished_at) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@ class BackupController extends Controller
|
||||
{
|
||||
public function download()
|
||||
{
|
||||
// Security: Only super admins can download backups containing all tenant data
|
||||
if (!auth()->user()->isSuperAdmin()) {
|
||||
abort(403, 'Seuls les super administrateurs peuvent télécharger des sauvegardes.');
|
||||
}
|
||||
|
||||
$databaseName = env('DB_DATABASE');
|
||||
$userName = env('DB_USERNAME');
|
||||
$password = env('DB_PASSWORD');
|
||||
|
||||
@@ -139,8 +139,20 @@ class CandidateController extends Controller
|
||||
$request->validate([
|
||||
'cv' => 'nullable|file|mimes:pdf|max:5120',
|
||||
'cover_letter' => 'nullable|file|mimes:pdf|max:5120',
|
||||
'name' => 'nullable|string|max:255',
|
||||
'email' => 'nullable|string|email|max:255|unique:users,email,' . $candidate->user_id,
|
||||
'phone' => 'nullable|string|max:255',
|
||||
'linkedin_url' => 'nullable|url|max:255',
|
||||
]);
|
||||
|
||||
// Update User info if name or email present
|
||||
if ($request->has('name') || $request->has('email')) {
|
||||
$candidate->user->update($request->only(['name', 'email']));
|
||||
}
|
||||
|
||||
// Update Candidate info
|
||||
$candidate->update($request->only(['phone', 'linkedin_url']));
|
||||
|
||||
if ($request->hasFile('cv')) {
|
||||
$this->replaceDocument($candidate, $request->file('cv'), 'cv');
|
||||
}
|
||||
@@ -149,20 +161,24 @@ class CandidateController extends Controller
|
||||
$this->replaceDocument($candidate, $request->file('cover_letter'), 'cover_letter');
|
||||
}
|
||||
|
||||
return back()->with('success', 'Documents mis à jour avec succès.');
|
||||
return back()->with('success', 'Profil mis à jour avec succès.');
|
||||
}
|
||||
|
||||
public function updateNotes(Request $request, Candidate $candidate)
|
||||
{
|
||||
$request->validate([
|
||||
'notes' => 'nullable|string',
|
||||
'interview_details' => 'nullable|array',
|
||||
'interview_score' => 'nullable|numeric|min:0|max:30',
|
||||
]);
|
||||
|
||||
$candidate->update([
|
||||
'notes' => $request->notes,
|
||||
'interview_details' => $request->interview_details,
|
||||
'interview_score' => $request->has('interview_score') ? $request->interview_score : $candidate->interview_score,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Notes mises à jour avec succès.');
|
||||
return back()->with('success', 'Entretien mis à jour avec succès.');
|
||||
}
|
||||
|
||||
public function updateScores(Request $request, Candidate $candidate)
|
||||
|
||||
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
|
||||
#[Fillable(['user_id', 'job_position_id', 'phone', 'linkedin_url', 'status', 'is_selected', 'notes', 'cv_score', 'motivation_score', 'interview_score', 'ai_analysis', 'tenant_id'])]
|
||||
#[Fillable(['user_id', 'job_position_id', 'phone', 'linkedin_url', 'status', 'is_selected', 'notes', 'cv_score', 'motivation_score', 'interview_score', 'interview_details', 'ai_analysis', 'tenant_id'])]
|
||||
class Candidate extends Model
|
||||
{
|
||||
use HasFactory, BelongsToTenant;
|
||||
@@ -31,6 +31,7 @@ class Candidate extends Model
|
||||
protected $casts = [
|
||||
'ai_analysis' => 'array',
|
||||
'is_selected' => 'boolean',
|
||||
'interview_details' => 'array',
|
||||
];
|
||||
|
||||
public function jobPosition(): BelongsTo
|
||||
|
||||
@@ -9,13 +9,14 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
|
||||
#[Fillable(['title', 'description', 'requirements', 'ai_prompt', 'tenant_id'])]
|
||||
#[Fillable(['title', 'description', 'requirements', 'ai_prompt', 'gemini_cache_id', 'gemini_cache_expires_at', 'tenant_id'])]
|
||||
class JobPosition extends Model
|
||||
{
|
||||
use HasFactory, BelongsToTenant;
|
||||
|
||||
protected $casts = [
|
||||
'requirements' => 'array',
|
||||
'gemini_cache_expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function candidates(): HasMany
|
||||
|
||||
@@ -79,14 +79,16 @@ class AIAnalysisService
|
||||
{
|
||||
$provider = $provider ?: env('AI_DEFAULT_PROVIDER', 'ollama');
|
||||
|
||||
$jobTitle = $candidate->jobPosition->title;
|
||||
$jobDesc = $candidate->jobPosition->description;
|
||||
$requirements = implode(", ", $candidate->jobPosition->requirements ?? []);
|
||||
$job = $candidate->jobPosition;
|
||||
$jobTitle = $job->title;
|
||||
$jobDesc = $job->description;
|
||||
$requirements = implode(", ", $job->requirements ?? []);
|
||||
|
||||
$prompt = "Tu es un expert en recrutement technique. Ton rôle est d'analyser le profil d'un candidat pour le poste de '{$jobTitle}'.";
|
||||
// Static Part: The job context and instructions (Cacheable)
|
||||
$staticPrompt = "Tu es un expert en recrutement technique spécialisé dans l'infrastructure et la cybersécurité. Ton rôle est d'analyser le profil d'un candidat pour le poste de '{$jobTitle}'.";
|
||||
|
||||
if (!$candidate->jobPosition->ai_prompt) {
|
||||
$prompt .= " Attache une grande importance aux compétences techniques et à l'expérience, mais aussi à la capacité d'intégration et à la motivation.
|
||||
if (!$job->ai_prompt) {
|
||||
$staticPrompt .= " Attache une grande importance aux compétences techniques et à l'expérience, mais aussi à la capacité d'intégration et à la motivation.
|
||||
|
||||
DESCRIPTION DU POSTE:
|
||||
{$jobDesc}
|
||||
@@ -94,11 +96,6 @@ class AIAnalysisService
|
||||
COMPÉTENCES REQUISES:
|
||||
{$requirements}
|
||||
|
||||
CONTENU DU CV:
|
||||
{$cvText}
|
||||
CONTENU DE LA LETTRE DE MOTIVATION:
|
||||
" . ($letterText ?? "Non fournie") . "
|
||||
|
||||
Fournis une analyse structurée en JSON avec les clés suivantes:
|
||||
- match_score: note de 0 à 100
|
||||
- summary: résumé de 3-4 phrases sur le profil
|
||||
@@ -106,8 +103,7 @@ class AIAnalysisService
|
||||
- gaps: liste des compétences manquantes ou points de vigilance
|
||||
- verdict: une conclusion (Favorable, Très Favorable, Réservé, Défavorable)";
|
||||
} else {
|
||||
// Context injection for the custom prompt
|
||||
$prompt .= "
|
||||
$staticPrompt .= "
|
||||
|
||||
CONTEXTE DU POSTE:
|
||||
{$jobDesc}
|
||||
@@ -115,23 +111,23 @@ class AIAnalysisService
|
||||
COMPÉTENCES REQUISES:
|
||||
{$requirements}
|
||||
|
||||
CONTENU DU CV DU CANDIDAT:
|
||||
{$cvText}
|
||||
|
||||
CONTENU DE LA LETTRE DE MOTIVATION:
|
||||
" . ($letterText ?? "Non fournie") . "
|
||||
|
||||
CONSIGNES D'ANALYSE SPÉCIFIQUES:
|
||||
" . $candidate->jobPosition->ai_prompt;
|
||||
" . $job->ai_prompt;
|
||||
}
|
||||
|
||||
$prompt .= "\n\nRéponds UNIQUEMENT en JSON pur, sans texte avant ou après. Assure-toi que le JSON est valide.";
|
||||
$staticPrompt .= "\n\nRéponds UNIQUEMENT en JSON pur, sans texte avant ou après. Assure-toi que le JSON est valide.";
|
||||
|
||||
// Dynamic Part: The candidate data (Not cached)
|
||||
$dynamicPrompt = "CONTENU DU CV DU CANDIDAT:\n{$cvText}\n\nCONTENU DE LA LETTRE DE MOTIVATION:\n" . ($letterText ?? "Non fournie");
|
||||
|
||||
// Full prompt for providers not using context caching
|
||||
$fullPrompt = $staticPrompt . "\n\n" . $dynamicPrompt;
|
||||
|
||||
$analysis = match ($provider) {
|
||||
'openai' => $this->callOpenAI($prompt),
|
||||
'anthropic' => $this->callAnthropic($prompt),
|
||||
'gemini' => $this->callGemini($prompt),
|
||||
default => $this->callOllama($prompt),
|
||||
'openai' => $this->callOpenAI($fullPrompt),
|
||||
'anthropic' => $this->callAnthropic($fullPrompt),
|
||||
'gemini' => $this->callGemini($dynamicPrompt, $staticPrompt, $job),
|
||||
default => $this->callOllama($fullPrompt),
|
||||
};
|
||||
|
||||
// Normalize keys for frontend compatibility
|
||||
@@ -257,29 +253,122 @@ class AIAnalysisService
|
||||
return $this->getSimulatedAnalysis();
|
||||
}
|
||||
|
||||
protected function callGemini(string $prompt)
|
||||
protected function callGemini(string $dynamicPrompt, ?string $staticPrompt = null, ?\App\Models\JobPosition $job = null)
|
||||
{
|
||||
$apiKey = env('GEMINI_API_KEY');
|
||||
if (!$apiKey) return $this->getSimulatedAnalysis();
|
||||
|
||||
try {
|
||||
$response = Http::timeout(60)->post("https://generativelanguage.googleapis.com/v1/models/gemini-2.5-flash:generateContent?key=" . $apiKey, [
|
||||
'contents' => [['parts' => [['text' => $prompt]]]]
|
||||
]);
|
||||
// Models to try in order (Updated for 2026 models)
|
||||
$models = [
|
||||
'gemini-3.1-flash-lite-preview',
|
||||
'gemini-3-flash-preview',
|
||||
'gemini-1.5-flash-latest'
|
||||
];
|
||||
|
||||
if ($response->successful()) {
|
||||
$text = $response->json('candidates.0.content.parts.0.text');
|
||||
return json_decode($this->extractJson($text), true);
|
||||
} else {
|
||||
Log::error("Gemini API Error: " . $response->status() . " - " . $response->body());
|
||||
foreach ($models as $model) {
|
||||
try {
|
||||
$version = (str_contains($model, '2.0') || str_contains($model, '3.')) ? 'v1beta' : 'v1';
|
||||
$url = "https://generativelanguage.googleapis.com/{$version}/models/{$model}:generateContent?key=" . $apiKey;
|
||||
|
||||
$generationConfig = [
|
||||
'temperature' => 0.2,
|
||||
'responseMimeType' => 'application/json'
|
||||
];
|
||||
|
||||
$payload = [
|
||||
'generationConfig' => $generationConfig,
|
||||
'contents' => [
|
||||
['role' => 'user', 'parts' => [['text' => $dynamicPrompt]]]
|
||||
]
|
||||
];
|
||||
|
||||
// Attempt to use Context Caching if static prompt and job are provided
|
||||
if ($staticPrompt && $job && $version === 'v1beta') {
|
||||
$cacheId = $this->getOrCreateContextCache($job, $staticPrompt, $model);
|
||||
if ($cacheId) {
|
||||
$payload['cachedContent'] = $cacheId;
|
||||
// When using cache, the static part is already in the cache
|
||||
} else {
|
||||
// Fallback: prepend static part if cache fails
|
||||
$payload['contents'][0]['parts'][0]['text'] = $staticPrompt . "\n\n" . $dynamicPrompt;
|
||||
}
|
||||
} else if ($staticPrompt) {
|
||||
// Non-cached fallback
|
||||
$payload['contents'][0]['parts'][0]['text'] = $staticPrompt . "\n\n" . $dynamicPrompt;
|
||||
}
|
||||
|
||||
$response = Http::timeout(60)->post($url, $payload);
|
||||
|
||||
if ($response->successful()) {
|
||||
$candidate = $response->json('candidates.0');
|
||||
if (isset($candidate['finishReason']) && $candidate['finishReason'] !== 'STOP') {
|
||||
Log::warning("Gemini warning: Analysis finished with reason " . $candidate['finishReason']);
|
||||
}
|
||||
|
||||
$text = $candidate['content']['parts'][0]['text'] ?? null;
|
||||
if ($text) {
|
||||
$json = $this->extractJson($text);
|
||||
$decoded = json_decode($json, true);
|
||||
if ($decoded) return $decoded;
|
||||
}
|
||||
} else {
|
||||
Log::error("Gemini API Error ($model): " . $response->status() . " - " . $response->body());
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Gemini Connection Failed ($model): " . $e->getMessage());
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Gemini Connection Failed: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->getSimulatedAnalysis();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a Gemini Context Cache for a specific Job Position.
|
||||
*/
|
||||
protected function getOrCreateContextCache(\App\Models\JobPosition $job, string $staticPrompt, string $model)
|
||||
{
|
||||
|
||||
if (strlen($staticPrompt) < 120000) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if we already have a valid cache for this job
|
||||
if ($job->gemini_cache_id && $job->gemini_cache_expires_at && $job->gemini_cache_expires_at->isFuture()) {
|
||||
// Basic verification: the cache is tied to a specific model
|
||||
// We assume the stored cache is for the primary model
|
||||
return $job->gemini_cache_id;
|
||||
}
|
||||
|
||||
$apiKey = env('GEMINI_API_KEY');
|
||||
try {
|
||||
// Create Context Cache (TTL of 1 hour)
|
||||
$response = Http::timeout(30)->post("https://generativelanguage.googleapis.com/v1beta/cachedContents?key=" . $apiKey, [
|
||||
'model' => "models/{$model}",
|
||||
'contents' => [
|
||||
['role' => 'user', 'parts' => [['text' => $staticPrompt]]]
|
||||
],
|
||||
'ttl' => '3600s'
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$cacheId = $response->json('name');
|
||||
$job->update([
|
||||
'gemini_cache_id' => $cacheId,
|
||||
'gemini_cache_expires_at' => now()->addHour()
|
||||
]);
|
||||
return $cacheId;
|
||||
}
|
||||
|
||||
// Log l'erreur pour comprendre pourquoi le cache a été refusé
|
||||
Log::warning("Gemini Cache Refused: " . $response->body());
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Gemini Cache Lifecycle Error: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function extractJson($string)
|
||||
{
|
||||
preg_match('/\{.*\}/s', $string, $matches);
|
||||
|
||||
@@ -18,12 +18,9 @@ trait BelongsToTenant
|
||||
return;
|
||||
}
|
||||
|
||||
// Candidates don't have a tenant_id but must access
|
||||
// quizzes/job positions linked to their position
|
||||
if ($user->role === 'candidate') {
|
||||
return;
|
||||
}
|
||||
|
||||
// All other users (admins and candidates) are filtered by their tenant.
|
||||
// This includes candidates, who must only see data from their own organization.
|
||||
// Resources with a null tenant_id are considered global and always visible.
|
||||
if ($user->tenant_id) {
|
||||
$builder->where(function ($query) use ($user) {
|
||||
$query->where('tenant_id', $user->tenant_id)
|
||||
|
||||
Reference in New Issue
Block a user