init commit
This commit is contained in:
345
app/Repositories/BaseRepository.php
Normal file
345
app/Repositories/BaseRepository.php
Normal file
@@ -0,0 +1,345 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Interfaces\BaseRepositoryInterface;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
|
||||
/**
|
||||
* Base Repository Implementation
|
||||
*
|
||||
* Provides common repository functionality for all models.
|
||||
* Implements the BaseRepositoryInterface with reusable methods.
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @since February 2025
|
||||
*/
|
||||
abstract class BaseRepository implements BaseRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* The model instance
|
||||
*
|
||||
* @var Model
|
||||
*/
|
||||
protected Model $model;
|
||||
|
||||
/**
|
||||
* The query builder instance
|
||||
*
|
||||
* @var Builder
|
||||
*/
|
||||
protected Builder $query;
|
||||
|
||||
/**
|
||||
* Create a new repository instance
|
||||
*
|
||||
* @param Model $model
|
||||
*/
|
||||
public function __construct(Model $model)
|
||||
{
|
||||
$this->model = $model;
|
||||
$this->resetQuery();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all records
|
||||
*
|
||||
* @param array $columns
|
||||
* @return Collection
|
||||
*/
|
||||
public function all(array $columns = ['*']): Collection
|
||||
{
|
||||
$result = $this->query->get($columns);
|
||||
$this->resetQuery();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get records with pagination
|
||||
*
|
||||
* @param int $perPage
|
||||
* @param array $columns
|
||||
* @return LengthAwarePaginator
|
||||
*/
|
||||
public function paginate(int $perPage = 15, array $columns = ['*']): LengthAwarePaginator
|
||||
{
|
||||
$result = $this->query->paginate($perPage, $columns);
|
||||
$this->resetQuery();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a record by ID
|
||||
*
|
||||
* @param int $id
|
||||
* @param array $columns
|
||||
* @return Model|null
|
||||
*/
|
||||
public function find(int $id, array $columns = ['*']): ?Model
|
||||
{
|
||||
$result = $this->query->find($id, $columns);
|
||||
$this->resetQuery();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a record by ID or throw an exception
|
||||
*
|
||||
* @param int $id
|
||||
* @param array $columns
|
||||
* @return Model
|
||||
* @throws ModelNotFoundException
|
||||
*/
|
||||
public function findOrFail(int $id, array $columns = ['*']): Model
|
||||
{
|
||||
$result = $this->query->findOrFail($id, $columns);
|
||||
$this->resetQuery();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find records by criteria
|
||||
*
|
||||
* @param array $criteria
|
||||
* @param array $columns
|
||||
* @return Collection
|
||||
*/
|
||||
public function findBy(array $criteria, array $columns = ['*']): Collection
|
||||
{
|
||||
$this->applyCriteria($criteria);
|
||||
$result = $this->query->get($columns);
|
||||
$this->resetQuery();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single record by criteria
|
||||
*
|
||||
* @param array $criteria
|
||||
* @param array $columns
|
||||
* @return Model|null
|
||||
*/
|
||||
public function findOneBy(array $criteria, array $columns = ['*']): ?Model
|
||||
{
|
||||
$this->applyCriteria($criteria);
|
||||
$result = $this->query->first($columns);
|
||||
$this->resetQuery();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new record
|
||||
*
|
||||
* @param array $data
|
||||
* @return Model
|
||||
*/
|
||||
public function create(array $data): Model
|
||||
{
|
||||
return $this->model->create($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing record
|
||||
*
|
||||
* @param int $id
|
||||
* @param array $data
|
||||
* @return bool
|
||||
*/
|
||||
public function update(int $id, array $data): bool
|
||||
{
|
||||
$model = $this->findOrFail($id);
|
||||
return $model->update($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or create a record
|
||||
*
|
||||
* @param array $attributes
|
||||
* @param array $values
|
||||
* @return Model
|
||||
*/
|
||||
public function updateOrCreate(array $attributes, array $values = []): Model
|
||||
{
|
||||
return $this->model->updateOrCreate($attributes, $values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a record by ID
|
||||
*
|
||||
* @param int $id
|
||||
* @return bool
|
||||
*/
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
$model = $this->findOrFail($id);
|
||||
return $model->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete records by criteria
|
||||
*
|
||||
* @param array $criteria
|
||||
* @return int Number of deleted records
|
||||
*/
|
||||
public function deleteBy(array $criteria): int
|
||||
{
|
||||
$this->applyCriteria($criteria);
|
||||
$result = $this->query->delete();
|
||||
$this->resetQuery();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count records
|
||||
*
|
||||
* @param array $criteria
|
||||
* @return int
|
||||
*/
|
||||
public function count(array $criteria = []): int
|
||||
{
|
||||
if (!empty($criteria)) {
|
||||
$this->applyCriteria($criteria);
|
||||
}
|
||||
|
||||
$result = $this->query->count();
|
||||
$this->resetQuery();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if record exists
|
||||
*
|
||||
* @param array $criteria
|
||||
* @return bool
|
||||
*/
|
||||
public function exists(array $criteria): bool
|
||||
{
|
||||
$this->applyCriteria($criteria);
|
||||
$result = $this->query->exists();
|
||||
$this->resetQuery();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get records with relationships
|
||||
*
|
||||
* @param array $relations
|
||||
* @return BaseRepositoryInterface
|
||||
*/
|
||||
public function with(array $relations): BaseRepositoryInterface
|
||||
{
|
||||
$this->query = $this->query->with($relations);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order records
|
||||
*
|
||||
* @param string $column
|
||||
* @param string $direction
|
||||
* @return BaseRepositoryInterface
|
||||
*/
|
||||
public function orderBy(string $column, string $direction = 'asc'): BaseRepositoryInterface
|
||||
{
|
||||
$this->query = $this->query->orderBy($column, $direction);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit records
|
||||
*
|
||||
* @param int $limit
|
||||
* @return BaseRepositoryInterface
|
||||
*/
|
||||
public function limit(int $limit): BaseRepositoryInterface
|
||||
{
|
||||
$this->query = $this->query->limit($limit);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply criteria to the query
|
||||
*
|
||||
* @param array $criteria
|
||||
* @return void
|
||||
*/
|
||||
protected function applyCriteria(array $criteria): void
|
||||
{
|
||||
foreach ($criteria as $field => $value) {
|
||||
if (is_array($value)) {
|
||||
$this->query = $this->query->whereIn($field, $value);
|
||||
} elseif (is_null($value)) {
|
||||
$this->query = $this->query->whereNull($field);
|
||||
} else {
|
||||
$this->query = $this->query->where($field, $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the query builder
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function resetQuery(): void
|
||||
{
|
||||
$this->query = $this->model->newQuery();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model instance
|
||||
*
|
||||
* @return Model
|
||||
*/
|
||||
public function getModel(): Model
|
||||
{
|
||||
return $this->model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the model instance
|
||||
*
|
||||
* @param Model $model
|
||||
* @return void
|
||||
*/
|
||||
public function setModel(Model $model): void
|
||||
{
|
||||
$this->model = $model;
|
||||
$this->resetQuery();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a callback within a database transaction
|
||||
*
|
||||
* @param callable $callback
|
||||
* @return mixed
|
||||
* @throws \Throwable
|
||||
*/
|
||||
protected function transaction(callable $callback)
|
||||
{
|
||||
return $this->model->getConnection()->transaction($callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fresh timestamp for the model
|
||||
*
|
||||
* @return \Carbon\Carbon
|
||||
*/
|
||||
protected function freshTimestamp(): \Carbon\Carbon
|
||||
{
|
||||
return $this->model->freshTimestamp();
|
||||
}
|
||||
}
|
||||
522
app/Repositories/ResumeRepository.php
Normal file
522
app/Repositories/ResumeRepository.php
Normal file
@@ -0,0 +1,522 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Interfaces\ResumeRepositoryInterface;
|
||||
use App\Models\Resume;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* Resume Repository Implementation
|
||||
*
|
||||
* Handles all resume-related data operations.
|
||||
* Implements ResumeRepositoryInterface with resume-specific business logic.
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @since February 2025
|
||||
*/
|
||||
class ResumeRepository extends BaseRepository implements ResumeRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* Create a new ResumeRepository instance
|
||||
*
|
||||
* @param Resume $model
|
||||
*/
|
||||
public function __construct(Resume $model)
|
||||
{
|
||||
parent::__construct($model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's resumes
|
||||
*
|
||||
* @param int $userId
|
||||
* @param array $columns
|
||||
* @return Collection
|
||||
*/
|
||||
public function getUserResumes(int $userId, array $columns = ['*']): Collection
|
||||
{
|
||||
return $this->orderBy('updated_at', 'desc')
|
||||
->findBy(['user_id' => $userId], $columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's resumes with pagination
|
||||
*
|
||||
* @param int $userId
|
||||
* @param int $perPage
|
||||
* @return LengthAwarePaginator
|
||||
*/
|
||||
public function getPaginatedUserResumes(int $userId, int $perPage = 10): LengthAwarePaginator
|
||||
{
|
||||
$result = $this->query
|
||||
->where('user_id', $userId)
|
||||
->orderBy('updated_at', 'desc')
|
||||
->paginate($perPage);
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find resume by public URL
|
||||
*
|
||||
* @param string $publicUrl
|
||||
* @return Resume|null
|
||||
*/
|
||||
public function findByPublicUrl(string $publicUrl): ?Resume
|
||||
{
|
||||
return $this->findOneBy([
|
||||
'public_url' => $publicUrl,
|
||||
'is_public' => true,
|
||||
'status' => 'published'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public resumes
|
||||
*
|
||||
* @param int $limit
|
||||
* @return Collection
|
||||
*/
|
||||
public function getPublicResumes(int $limit = 20): Collection
|
||||
{
|
||||
$result = $this->query
|
||||
->where('is_public', true)
|
||||
->where('status', 'published')
|
||||
->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', Carbon::now())
|
||||
->orderBy('published_at', 'desc')
|
||||
->limit($limit)
|
||||
->with(['user:id,first_name,last_name'])
|
||||
->get();
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resumes by template
|
||||
*
|
||||
* @param string $template
|
||||
* @return Collection
|
||||
*/
|
||||
public function getResumesByTemplate(string $template): Collection
|
||||
{
|
||||
return $this->orderBy('created_at', 'desc')
|
||||
->findBy(['template' => $template]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resumes by status
|
||||
*
|
||||
* @param string $status
|
||||
* @return Collection
|
||||
*/
|
||||
public function getResumesByStatus(string $status): Collection
|
||||
{
|
||||
return $this->orderBy('updated_at', 'desc')
|
||||
->findBy(['status' => $status]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get popular resumes (most viewed)
|
||||
*
|
||||
* @param int $limit
|
||||
* @param int $days
|
||||
* @return Collection
|
||||
*/
|
||||
public function getPopularResumes(int $limit = 10, int $days = 30): Collection
|
||||
{
|
||||
$cutoffDate = Carbon::now()->subDays($days);
|
||||
|
||||
$result = $this->query
|
||||
->where('is_public', true)
|
||||
->where('status', 'published')
|
||||
->where('last_viewed_at', '>=', $cutoffDate)
|
||||
->orderBy('view_count', 'desc')
|
||||
->limit($limit)
|
||||
->with(['user:id,first_name,last_name'])
|
||||
->get();
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent resumes
|
||||
*
|
||||
* @param int $limit
|
||||
* @param int $days
|
||||
* @return Collection
|
||||
*/
|
||||
public function getRecentResumes(int $limit = 10, int $days = 7): Collection
|
||||
{
|
||||
$cutoffDate = Carbon::now()->subDays($days);
|
||||
|
||||
$result = $this->query
|
||||
->where('is_public', true)
|
||||
->where('status', 'published')
|
||||
->where('published_at', '>=', $cutoffDate)
|
||||
->orderBy('published_at', 'desc')
|
||||
->limit($limit)
|
||||
->with(['user:id,first_name,last_name'])
|
||||
->get();
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search resumes
|
||||
*
|
||||
* @param string $query
|
||||
* @param array $filters
|
||||
* @param int $limit
|
||||
* @return Collection
|
||||
*/
|
||||
public function searchResumes(string $query, array $filters = [], int $limit = 20): Collection
|
||||
{
|
||||
$searchTerm = '%' . $query . '%';
|
||||
|
||||
$queryBuilder = $this->query
|
||||
->where('is_public', true)
|
||||
->where('status', 'published')
|
||||
->where(function ($q) use ($searchTerm) {
|
||||
$q->where('title', 'like', $searchTerm)
|
||||
->orWhere('description', 'like', $searchTerm)
|
||||
->orWhereJsonContains('content->summary', $searchTerm)
|
||||
->orWhereJsonLike('content->skills', $searchTerm);
|
||||
});
|
||||
|
||||
// Apply filters
|
||||
if (isset($filters['template']) && !empty($filters['template'])) {
|
||||
$queryBuilder = $queryBuilder->where('template', $filters['template']);
|
||||
}
|
||||
|
||||
if (isset($filters['min_completion']) && is_numeric($filters['min_completion'])) {
|
||||
$queryBuilder = $queryBuilder->where('completion_percentage', '>=', $filters['min_completion']);
|
||||
}
|
||||
|
||||
$result = $queryBuilder
|
||||
->orderBy('view_count', 'desc')
|
||||
->limit($limit)
|
||||
->with(['user:id,first_name,last_name'])
|
||||
->get();
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get incomplete resumes
|
||||
*
|
||||
* @param int $threshold
|
||||
* @return Collection
|
||||
*/
|
||||
public function getIncompleteResumes(int $threshold = 50): Collection
|
||||
{
|
||||
$result = $this->query
|
||||
->where('completion_percentage', '<', $threshold)
|
||||
->where('status', 'draft')
|
||||
->orderBy('updated_at', 'desc')
|
||||
->with(['user:id,first_name,last_name,email'])
|
||||
->get();
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate resume for user
|
||||
*
|
||||
* @param int $resumeId
|
||||
* @param int $userId
|
||||
* @param string $newTitle
|
||||
* @return Resume
|
||||
*/
|
||||
public function duplicateResume(int $resumeId, int $userId, string $newTitle): Resume
|
||||
{
|
||||
return $this->transaction(function () use ($resumeId, $userId, $newTitle) {
|
||||
$originalResume = $this->findOrFail($resumeId);
|
||||
|
||||
$duplicateData = $originalResume->toArray();
|
||||
|
||||
// Remove unique fields and reset for new resume
|
||||
unset($duplicateData['id'], $duplicateData['created_at'], $duplicateData['updated_at'], $duplicateData['deleted_at']);
|
||||
|
||||
$duplicateData['user_id'] = $userId;
|
||||
$duplicateData['title'] = $newTitle;
|
||||
$duplicateData['status'] = 'draft';
|
||||
$duplicateData['is_public'] = false;
|
||||
$duplicateData['public_url'] = null;
|
||||
$duplicateData['published_at'] = null;
|
||||
$duplicateData['view_count'] = 0;
|
||||
$duplicateData['download_count'] = 0;
|
||||
$duplicateData['last_viewed_at'] = null;
|
||||
$duplicateData['last_downloaded_at'] = null;
|
||||
$duplicateData['pdf_path'] = null;
|
||||
$duplicateData['pdf_generated_at'] = null;
|
||||
$duplicateData['pdf_version'] = 1;
|
||||
|
||||
return $this->create($duplicateData);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update resume view count
|
||||
*
|
||||
* @param int $resumeId
|
||||
* @return bool
|
||||
*/
|
||||
public function incrementViewCount(int $resumeId): bool
|
||||
{
|
||||
$resume = $this->find($resumeId);
|
||||
|
||||
if (!$resume) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $resume->update([
|
||||
'view_count' => $resume->view_count + 1,
|
||||
'last_viewed_at' => $this->freshTimestamp(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update resume download count
|
||||
*
|
||||
* @param int $resumeId
|
||||
* @return bool
|
||||
*/
|
||||
public function incrementDownloadCount(int $resumeId): bool
|
||||
{
|
||||
$resume = $this->find($resumeId);
|
||||
|
||||
if (!$resume) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $resume->update([
|
||||
'download_count' => $resume->download_count + 1,
|
||||
'last_downloaded_at' => $this->freshTimestamp(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resume statistics
|
||||
*
|
||||
* @param int|null $userId
|
||||
* @return array
|
||||
*/
|
||||
public function getResumeStatistics(?int $userId = null): array
|
||||
{
|
||||
$query = $this->query;
|
||||
|
||||
if ($userId) {
|
||||
$query = $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
$stats = [
|
||||
'total_resumes' => $query->count(),
|
||||
'draft_resumes' => $query->where('status', 'draft')->count(),
|
||||
'published_resumes' => $query->where('status', 'published')->count(),
|
||||
'archived_resumes' => $query->where('status', 'archived')->count(),
|
||||
'public_resumes' => $query->where('is_public', true)->count(),
|
||||
'private_resumes' => $query->where('is_public', false)->count(),
|
||||
];
|
||||
|
||||
// Template statistics
|
||||
$templateStats = $query->select('template', DB::raw('count(*) as count'))
|
||||
->groupBy('template')
|
||||
->pluck('count', 'template')
|
||||
->toArray();
|
||||
$stats['by_template'] = $templateStats;
|
||||
|
||||
// Completion statistics
|
||||
$stats['avg_completion'] = $query->avg('completion_percentage') ?? 0;
|
||||
$stats['completed_resumes'] = $query->where('completion_percentage', '>=', 90)->count();
|
||||
|
||||
// View statistics
|
||||
$stats['total_views'] = $query->sum('view_count');
|
||||
$stats['total_downloads'] = $query->sum('download_count');
|
||||
|
||||
// Recent activity (last 30 days)
|
||||
$thirtyDaysAgo = Carbon::now()->subDays(30);
|
||||
$stats['created_last_30_days'] = $query->where('created_at', '>=', $thirtyDaysAgo)->count();
|
||||
$stats['updated_last_30_days'] = $query->where('updated_at', '>=', $thirtyDaysAgo)->count();
|
||||
|
||||
$this->resetQuery();
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resumes requiring PDF regeneration
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getResumesNeedingPdfRegeneration(): Collection
|
||||
{
|
||||
$result = $this->query
|
||||
->where('status', 'published')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('pdf_generated_at')
|
||||
->orWhere('updated_at', '>', DB::raw('pdf_generated_at'));
|
||||
})
|
||||
->orderBy('updated_at', 'desc')
|
||||
->get();
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive old resumes
|
||||
*
|
||||
* @param int $days
|
||||
* @return int Number of archived resumes
|
||||
*/
|
||||
public function archiveOldResumes(int $days = 365): int
|
||||
{
|
||||
$cutoffDate = Carbon::now()->subDays($days);
|
||||
|
||||
$result = $this->query
|
||||
->where('status', 'draft')
|
||||
->where('updated_at', '<', $cutoffDate)
|
||||
->update(['status' => 'archived']);
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's resume by title
|
||||
*
|
||||
* @param int $userId
|
||||
* @param string $title
|
||||
* @return Resume|null
|
||||
*/
|
||||
public function getUserResumeByTitle(int $userId, string $title): ?Resume
|
||||
{
|
||||
return $this->findOneBy([
|
||||
'user_id' => $userId,
|
||||
'title' => $title
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can create more resumes
|
||||
*
|
||||
* @param int $userId
|
||||
* @param int $limit
|
||||
* @return bool
|
||||
*/
|
||||
public function canUserCreateMoreResumes(int $userId, int $limit = 10): bool
|
||||
{
|
||||
$userResumeCount = $this->count(['user_id' => $userId]);
|
||||
return $userResumeCount < $limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get featured resumes for showcase
|
||||
*
|
||||
* @param int $limit
|
||||
* @return Collection
|
||||
*/
|
||||
public function getFeaturedResumes(int $limit = 6): Collection
|
||||
{
|
||||
$result = $this->query
|
||||
->where('is_public', true)
|
||||
->where('status', 'published')
|
||||
->where('completion_percentage', '>=', 90)
|
||||
->where('view_count', '>', 50)
|
||||
->orderBy('view_count', 'desc')
|
||||
->orderBy('download_count', 'desc')
|
||||
->limit($limit)
|
||||
->with(['user:id,first_name,last_name'])
|
||||
->get();
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update resume completion percentage
|
||||
*
|
||||
* @param int $resumeId
|
||||
* @return bool
|
||||
*/
|
||||
public function updateCompletionPercentage(int $resumeId): bool
|
||||
{
|
||||
$resume = $this->find($resumeId);
|
||||
|
||||
if (!$resume) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$content = $resume->content ?? [];
|
||||
$sections = ['personal_info', 'summary', 'experience', 'education', 'skills'];
|
||||
$completedSections = 0;
|
||||
$totalSections = count($sections);
|
||||
|
||||
foreach ($sections as $section) {
|
||||
if (isset($content[$section]) && !empty($content[$section])) {
|
||||
$completedSections++;
|
||||
}
|
||||
}
|
||||
|
||||
$percentage = $totalSections > 0 ? intval(($completedSections / $totalSections) * 100) : 0;
|
||||
|
||||
return $resume->update([
|
||||
'completion_percentage' => $percentage,
|
||||
'completion_sections' => array_fill_keys($sections, false)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resumes expiring soon
|
||||
*
|
||||
* @param int $days
|
||||
* @return Collection
|
||||
*/
|
||||
public function getResumesExpiringSoon(int $days = 7): Collection
|
||||
{
|
||||
$futureDate = Carbon::now()->addDays($days);
|
||||
|
||||
$result = $this->query
|
||||
->where('is_public', true)
|
||||
->whereNotNull('expires_at')
|
||||
->whereBetween('expires_at', [Carbon::now(), $futureDate])
|
||||
->orderBy('expires_at', 'asc')
|
||||
->with(['user:id,first_name,last_name,email'])
|
||||
->get();
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk update resume status
|
||||
*
|
||||
* @param array $resumeIds
|
||||
* @param string $status
|
||||
* @return int Number of updated resumes
|
||||
*/
|
||||
public function bulkUpdateStatus(array $resumeIds, string $status): int
|
||||
{
|
||||
$result = $this->query
|
||||
->whereIn('id', $resumeIds)
|
||||
->update([
|
||||
'status' => $status,
|
||||
'updated_at' => $this->freshTimestamp()
|
||||
]);
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
410
app/Repositories/UserRepository.php
Normal file
410
app/Repositories/UserRepository.php
Normal file
@@ -0,0 +1,410 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Interfaces\UserRepositoryInterface;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* User Repository Implementation
|
||||
*
|
||||
* Handles all user-related data operations.
|
||||
* Implements UserRepositoryInterface with user-specific business logic.
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @since February 2025
|
||||
*/
|
||||
class UserRepository extends BaseRepository implements UserRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* Create a new UserRepository instance
|
||||
*
|
||||
* @param User $model
|
||||
*/
|
||||
public function __construct(User $model)
|
||||
{
|
||||
parent::__construct($model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a user by email address
|
||||
*
|
||||
* @param string $email
|
||||
* @return User|null
|
||||
*/
|
||||
public function findByEmail(string $email): ?User
|
||||
{
|
||||
return $this->findOneBy(['email' => $email]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find users by status
|
||||
*
|
||||
* @param string $status
|
||||
* @return Collection
|
||||
*/
|
||||
public function findByStatus(string $status): Collection
|
||||
{
|
||||
return $this->findBy(['status' => $status]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users who haven't logged in recently
|
||||
*
|
||||
* @param int $days
|
||||
* @return Collection
|
||||
*/
|
||||
public function getInactiveUsers(int $days = 30): Collection
|
||||
{
|
||||
$cutoffDate = Carbon::now()->subDays($days);
|
||||
|
||||
$result = $this->query
|
||||
->where(function ($query) use ($cutoffDate) {
|
||||
$query->where('last_login_at', '<', $cutoffDate)
|
||||
->orWhereNull('last_login_at');
|
||||
})
|
||||
->where('status', 'active')
|
||||
->orderBy('last_login_at', 'asc')
|
||||
->get();
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users with completed profiles
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getUsersWithCompletedProfiles(): Collection
|
||||
{
|
||||
$result = $this->query
|
||||
->whereNotNull('profile_completed_at')
|
||||
->where('status', 'active')
|
||||
->orderBy('profile_completed_at', 'desc')
|
||||
->get();
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users with incomplete profiles
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getUsersWithIncompleteProfiles(): Collection
|
||||
{
|
||||
$result = $this->query
|
||||
->whereNull('profile_completed_at')
|
||||
->where('status', 'active')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users subscribed to newsletter
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getNewsletterSubscribers(): Collection
|
||||
{
|
||||
return $this->findBy([
|
||||
'newsletter_subscribed' => true,
|
||||
'status' => 'active'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search users by name or email
|
||||
*
|
||||
* @param string $query
|
||||
* @param int $limit
|
||||
* @return Collection
|
||||
*/
|
||||
public function searchUsers(string $query, int $limit = 10): Collection
|
||||
{
|
||||
$searchTerm = '%' . $query . '%';
|
||||
|
||||
$result = $this->query
|
||||
->where(function ($q) use ($searchTerm) {
|
||||
$q->where('first_name', 'like', $searchTerm)
|
||||
->orWhere('last_name', 'like', $searchTerm)
|
||||
->orWhere('email', 'like', $searchTerm)
|
||||
->orWhereRaw("CONCAT(first_name, ' ', last_name) LIKE ?", [$searchTerm]);
|
||||
})
|
||||
->where('status', 'active')
|
||||
->orderBy('last_login_at', 'desc')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve comprehensive user statistics and analytics
|
||||
*
|
||||
* Aggregates key user metrics including:
|
||||
* - Total users by status (active, inactive, suspended)
|
||||
* - Email verification status counts
|
||||
* - Registration trends (30-day window)
|
||||
* - Profile completion metrics
|
||||
* - Newsletter subscription data
|
||||
* - Daily active user statistics
|
||||
*
|
||||
* Used for admin dashboards, reporting, and user behavior analysis.
|
||||
*
|
||||
* @return array Comprehensive statistics array with user metrics
|
||||
* @throws Exception When database queries fail
|
||||
*/
|
||||
public function getUserStatistics(): array
|
||||
{
|
||||
$stats = [
|
||||
'total_users' => $this->count(),
|
||||
'active_users' => $this->count(['status' => 'active']),
|
||||
'inactive_users' => $this->count(['status' => 'inactive']),
|
||||
'suspended_users' => $this->count(['status' => 'suspended']),
|
||||
'verified_users' => $this->query->whereNotNull('email_verified_at')->count(),
|
||||
'unverified_users' => $this->query->whereNull('email_verified_at')->count(),
|
||||
];
|
||||
|
||||
$thirtyDaysAgo = Carbon::now()->subDays(30);
|
||||
$stats['new_users_30_days'] = $this->query
|
||||
->where('created_at', '>=', $thirtyDaysAgo)
|
||||
->count();
|
||||
|
||||
$stats['completed_profiles'] = $this->query
|
||||
->whereNotNull('profile_completed_at')
|
||||
->count();
|
||||
|
||||
$stats['newsletter_subscribers'] = $this->count(['newsletter_subscribed' => true]);
|
||||
|
||||
$stats['active_today'] = $this->query
|
||||
->whereDate('last_login_at', Carbon::today())
|
||||
->count();
|
||||
|
||||
$this->resetQuery();
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user's last login metadata with security tracking
|
||||
*
|
||||
* Records successful login timestamp, IP address, and resets
|
||||
* security-related fields including login attempts and account locks.
|
||||
* Essential for authentication audit trails and security monitoring.
|
||||
*
|
||||
* @param int $userId The user ID to update
|
||||
* @param string $ipAddress Client IP address for security tracking
|
||||
* @return bool True if update succeeded, false otherwise
|
||||
* @throws Exception When user record update fails
|
||||
*/
|
||||
public function updateLastLogin(int $userId, string $ipAddress): bool
|
||||
{
|
||||
return $this->update($userId, [
|
||||
'last_login_at' => $this->freshTimestamp(),
|
||||
'last_login_ip' => $ipAddress,
|
||||
'login_attempts' => 0,
|
||||
'locked_until' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment failed login attempt counter for user account
|
||||
*
|
||||
* Increases the login_attempts field by 1 for security tracking.
|
||||
* Used in conjunction with account locking mechanisms to prevent
|
||||
* brute force attacks. Should be called after each failed login.
|
||||
*
|
||||
* @param string $email User email address to track attempts for
|
||||
* @return bool True if increment succeeded, false if user not found
|
||||
*/
|
||||
public function incrementLoginAttempts(string $email): bool
|
||||
{
|
||||
$user = $this->findByEmail($email);
|
||||
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->increment('login_attempts');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset user's login attempts
|
||||
*
|
||||
* @param string $email
|
||||
* @return bool
|
||||
*/
|
||||
public function resetLoginAttempts(string $email): bool
|
||||
{
|
||||
$user = $this->findByEmail($email);
|
||||
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->update([
|
||||
'login_attempts' => 0,
|
||||
'locked_until' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporarily lock user account for security purposes
|
||||
*
|
||||
* Implements account lockout mechanism to prevent brute force attacks
|
||||
* by setting locked_until timestamp. Account remains inaccessible
|
||||
* until the lockout period expires or is manually cleared.
|
||||
*
|
||||
* @param string $email User email address to lock
|
||||
* @param int $minutes Lockout duration in minutes (default: 15)
|
||||
* @return bool True if lock applied successfully, false if user not found
|
||||
*/
|
||||
public function lockAccount(string $email, int $minutes = 15): bool
|
||||
{
|
||||
$user = $this->findByEmail($email);
|
||||
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->update([
|
||||
'locked_until' => Carbon::now()->addMinutes($minutes),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if user account is currently locked
|
||||
*
|
||||
* Validates account lockout status by checking if locked_until
|
||||
* timestamp exists and is still in the future. Used before
|
||||
* authentication attempts to enforce security policies.
|
||||
*
|
||||
* @param string $email User email address to check
|
||||
* @return bool True if account is locked, false if accessible or user not found
|
||||
*/
|
||||
public function isAccountLocked(string $email): bool
|
||||
{
|
||||
$user = $this->findByEmail($email);
|
||||
|
||||
if (!$user || !$user->locked_until) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Carbon::now()->lessThan($user->locked_until);
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspend user account
|
||||
*
|
||||
* @param int $userId
|
||||
* @param string $reason
|
||||
* @return bool
|
||||
*/
|
||||
public function suspendAccount(int $userId, string $reason): bool
|
||||
{
|
||||
return $this->update($userId, [
|
||||
'status' => 'suspended',
|
||||
'suspended_at' => $this->freshTimestamp(),
|
||||
'suspension_reason' => $reason,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivate user account
|
||||
*
|
||||
* @param int $userId
|
||||
* @return bool
|
||||
*/
|
||||
public function reactivateAccount(int $userId): bool
|
||||
{
|
||||
return $this->update($userId, [
|
||||
'status' => 'active',
|
||||
'suspended_at' => null,
|
||||
'suspension_reason' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users registered in date range
|
||||
*
|
||||
* @param string $startDate
|
||||
* @param string $endDate
|
||||
* @return Collection
|
||||
*/
|
||||
public function getUsersRegisteredBetween(string $startDate, string $endDate): Collection
|
||||
{
|
||||
$result = $this->query
|
||||
->whereBetween('created_at', [$startDate, $endDate])
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
$this->resetQuery();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated users with filters
|
||||
*
|
||||
* @param array $filters
|
||||
* @param int $perPage
|
||||
* @return LengthAwarePaginator
|
||||
*/
|
||||
public function getPaginatedUsersWithFilters(array $filters = [], int $perPage = 15): LengthAwarePaginator
|
||||
{
|
||||
$query = $this->query;
|
||||
|
||||
// Apply filters
|
||||
if (isset($filters['status']) && !empty($filters['status'])) {
|
||||
$query = $query->where('status', $filters['status']);
|
||||
}
|
||||
|
||||
if (isset($filters['verified']) && $filters['verified'] !== '') {
|
||||
if ($filters['verified']) {
|
||||
$query = $query->whereNotNull('email_verified_at');
|
||||
} else {
|
||||
$query = $query->whereNull('email_verified_at');
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($filters['newsletter']) && $filters['newsletter'] !== '') {
|
||||
$query = $query->where('newsletter_subscribed', (bool) $filters['newsletter']);
|
||||
}
|
||||
|
||||
if (isset($filters['search']) && !empty($filters['search'])) {
|
||||
$searchTerm = '%' . $filters['search'] . '%';
|
||||
$query = $query->where(function ($q) use ($searchTerm) {
|
||||
$q->where('first_name', 'like', $searchTerm)
|
||||
->orWhere('last_name', 'like', $searchTerm)
|
||||
->orWhere('email', 'like', $searchTerm);
|
||||
});
|
||||
}
|
||||
|
||||
if (isset($filters['date_from']) && !empty($filters['date_from'])) {
|
||||
$query = $query->whereDate('created_at', '>=', $filters['date_from']);
|
||||
}
|
||||
|
||||
if (isset($filters['date_to']) && !empty($filters['date_to'])) {
|
||||
$query = $query->whereDate('created_at', '<=', $filters['date_to']);
|
||||
}
|
||||
|
||||
// Default ordering
|
||||
$sortBy = $filters['sort_by'] ?? 'created_at';
|
||||
$sortDirection = $filters['sort_direction'] ?? 'desc';
|
||||
$query = $query->orderBy($sortBy, $sortDirection);
|
||||
|
||||
$result = $query->paginate($perPage);
|
||||
$this->resetQuery();
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user