feat: person tags system - org-level skills with self-reported and organiser-assigned sources
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,29 @@ final class PersonController extends Controller
|
||||
$query->where('status', $request->input('status'));
|
||||
}
|
||||
|
||||
if ($request->filled('tag')) {
|
||||
$organisation = $event->organisation;
|
||||
$query->whereHas('user', function ($q) use ($request, $organisation) {
|
||||
$q->whereHas('organisationTags', function ($q2) use ($request, $organisation) {
|
||||
$q2->where('organisation_id', $organisation->id)
|
||||
->where('person_tag_id', $request->input('tag'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('tags')) {
|
||||
$organisation = $event->organisation;
|
||||
$tagIds = explode(',', $request->input('tags'));
|
||||
foreach ($tagIds as $tagId) {
|
||||
$query->whereHas('user', function ($q) use ($tagId, $organisation) {
|
||||
$q->whereHas('organisationTags', function ($q2) use ($tagId, $organisation) {
|
||||
$q2->where('organisation_id', $organisation->id)
|
||||
->where('person_tag_id', $tagId);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new PersonCollection($query->get());
|
||||
}
|
||||
|
||||
@@ -38,7 +61,7 @@ final class PersonController extends Controller
|
||||
{
|
||||
Gate::authorize('view', [$person, $event]);
|
||||
|
||||
$person->load(['crowdType', 'company']);
|
||||
$person->load(['crowdType', 'company', 'user']);
|
||||
|
||||
return $this->success(new PersonResource($person));
|
||||
}
|
||||
|
||||
67
api/app/Http/Controllers/Api/V1/PersonTagController.php
Normal file
67
api/app/Http/Controllers/Api/V1/PersonTagController.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\StorePersonTagRequest;
|
||||
use App\Http\Requests\Api\V1\UpdatePersonTagRequest;
|
||||
use App\Http\Resources\Api\V1\PersonTagResource;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\PersonTag;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
final class PersonTagController extends Controller
|
||||
{
|
||||
public function index(Organisation $organisation): AnonymousResourceCollection
|
||||
{
|
||||
Gate::authorize('viewAny', [PersonTag::class, $organisation]);
|
||||
|
||||
$tags = $organisation->personTags()->active()->ordered()->get();
|
||||
|
||||
return PersonTagResource::collection($tags);
|
||||
}
|
||||
|
||||
public function store(StorePersonTagRequest $request, Organisation $organisation): JsonResponse
|
||||
{
|
||||
Gate::authorize('create', [PersonTag::class, $organisation]);
|
||||
|
||||
$tag = $organisation->personTags()->create($request->validated());
|
||||
|
||||
return $this->created(new PersonTagResource($tag));
|
||||
}
|
||||
|
||||
public function update(UpdatePersonTagRequest $request, Organisation $organisation, PersonTag $personTag): JsonResponse
|
||||
{
|
||||
Gate::authorize('update', [$personTag, $organisation]);
|
||||
|
||||
$personTag->update($request->validated());
|
||||
|
||||
return $this->success(new PersonTagResource($personTag->fresh()));
|
||||
}
|
||||
|
||||
public function destroy(Organisation $organisation, PersonTag $personTag): JsonResponse
|
||||
{
|
||||
Gate::authorize('delete', [$personTag, $organisation]);
|
||||
|
||||
$personTag->update(['is_active' => false]);
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
public function categories(Organisation $organisation): JsonResponse
|
||||
{
|
||||
Gate::authorize('viewAny', [PersonTag::class, $organisation]);
|
||||
|
||||
$categories = $organisation->personTags()
|
||||
->whereNotNull('category')
|
||||
->distinct()
|
||||
->orderBy('category')
|
||||
->pluck('category');
|
||||
|
||||
return response()->json(['data' => $categories]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\StoreUserOrganisationTagRequest;
|
||||
use App\Http\Requests\Api\V1\SyncUserOrganisationTagsRequest;
|
||||
use App\Http\Resources\Api\V1\UserOrganisationTagResource;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\User;
|
||||
use App\Models\UserOrganisationTag;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
final class UserOrganisationTagController extends Controller
|
||||
{
|
||||
public function index(Organisation $organisation, User $user): AnonymousResourceCollection
|
||||
{
|
||||
Gate::authorize('viewAny', [UserOrganisationTag::class, $organisation]);
|
||||
|
||||
$tags = $user->organisationTags()
|
||||
->where('organisation_id', $organisation->id)
|
||||
->with(['personTag', 'assignedBy'])
|
||||
->get();
|
||||
|
||||
return UserOrganisationTagResource::collection($tags);
|
||||
}
|
||||
|
||||
public function store(StoreUserOrganisationTagRequest $request, Organisation $organisation, User $user): JsonResponse
|
||||
{
|
||||
Gate::authorize('create', [UserOrganisationTag::class, $organisation]);
|
||||
|
||||
$data = $request->validated();
|
||||
$data['user_id'] = $user->id;
|
||||
$data['organisation_id'] = $organisation->id;
|
||||
$data['assigned_at'] = now();
|
||||
|
||||
if ($data['source'] === 'organiser_assigned') {
|
||||
$data['assigned_by_user_id'] = $request->user()->id;
|
||||
}
|
||||
|
||||
$tag = UserOrganisationTag::create($data);
|
||||
$tag->load(['personTag', 'assignedBy']);
|
||||
|
||||
return $this->created(new UserOrganisationTagResource($tag));
|
||||
}
|
||||
|
||||
public function destroy(Organisation $organisation, User $user, UserOrganisationTag $userOrganisationTag): JsonResponse
|
||||
{
|
||||
Gate::authorize('delete', [$userOrganisationTag, $organisation]);
|
||||
|
||||
$userOrganisationTag->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
public function sync(SyncUserOrganisationTagsRequest $request, Organisation $organisation, User $user): AnonymousResourceCollection
|
||||
{
|
||||
Gate::authorize('sync', [UserOrganisationTag::class, $organisation]);
|
||||
|
||||
$source = $request->validated('source');
|
||||
$tagIds = $request->validated('tag_ids');
|
||||
|
||||
// Remove tags of this source that are not in the new list
|
||||
$user->organisationTags()
|
||||
->where('organisation_id', $organisation->id)
|
||||
->where('source', $source)
|
||||
->whereNotIn('person_tag_id', $tagIds)
|
||||
->delete();
|
||||
|
||||
// Add new tags that don't exist yet
|
||||
$existingTagIds = $user->organisationTags()
|
||||
->where('organisation_id', $organisation->id)
|
||||
->where('source', $source)
|
||||
->pluck('person_tag_id')
|
||||
->toArray();
|
||||
|
||||
$newTagIds = array_diff($tagIds, $existingTagIds);
|
||||
|
||||
foreach ($newTagIds as $tagId) {
|
||||
UserOrganisationTag::create([
|
||||
'user_id' => $user->id,
|
||||
'organisation_id' => $organisation->id,
|
||||
'person_tag_id' => $tagId,
|
||||
'source' => $source,
|
||||
'assigned_by_user_id' => $source === 'organiser_assigned' ? $request->user()->id : null,
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Return updated full tag list for this user in this org
|
||||
$tags = $user->organisationTags()
|
||||
->where('organisation_id', $organisation->id)
|
||||
->with(['personTag', 'assignedBy'])
|
||||
->get();
|
||||
|
||||
return UserOrganisationTagResource::collection($tags);
|
||||
}
|
||||
}
|
||||
33
api/app/Http/Requests/Api/V1/StorePersonTagRequest.php
Normal file
33
api/app/Http/Requests/Api/V1/StorePersonTagRequest.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class StorePersonTagRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:50',
|
||||
Rule::unique('person_tags')->where('organisation_id', $this->route('organisation')->id),
|
||||
],
|
||||
'category' => ['nullable', 'string', 'max:50'],
|
||||
'icon' => ['nullable', 'string', 'max:50'],
|
||||
'color' => ['nullable', 'string', 'max:7'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
'sort_order' => ['sometimes', 'integer'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class StoreUserOrganisationTagRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$organisationId = $this->route('organisation')->id;
|
||||
|
||||
return [
|
||||
'person_tag_id' => [
|
||||
'required',
|
||||
'ulid',
|
||||
Rule::exists('person_tags', 'id')->where('organisation_id', $organisationId),
|
||||
],
|
||||
'source' => ['required', 'in:self_reported,organiser_assigned'],
|
||||
'proficiency' => ['nullable', 'in:beginner,experienced,expert'],
|
||||
'notes' => ['nullable', 'string', 'max:500'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class SyncUserOrganisationTagsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$organisationId = $this->route('organisation')->id;
|
||||
|
||||
return [
|
||||
'tag_ids' => ['required', 'array'],
|
||||
'tag_ids.*' => [
|
||||
'ulid',
|
||||
Rule::exists('person_tags', 'id')->where('organisation_id', $organisationId),
|
||||
],
|
||||
'source' => ['required', 'in:self_reported,organiser_assigned'],
|
||||
];
|
||||
}
|
||||
}
|
||||
35
api/app/Http/Requests/Api/V1/UpdatePersonTagRequest.php
Normal file
35
api/app/Http/Requests/Api/V1/UpdatePersonTagRequest.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class UpdatePersonTagRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
'sometimes',
|
||||
'string',
|
||||
'max:50',
|
||||
Rule::unique('person_tags')
|
||||
->where('organisation_id', $this->route('organisation')->id)
|
||||
->ignore($this->route('person_tag')),
|
||||
],
|
||||
'category' => ['nullable', 'string', 'max:50'],
|
||||
'icon' => ['nullable', 'string', 'max:50'],
|
||||
'color' => ['nullable', 'string', 'max:7'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
'sort_order' => ['sometimes', 'integer'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,23 @@ final class PersonResource extends JsonResource
|
||||
'created_at' => $this->created_at->toIso8601String(),
|
||||
'crowd_type' => new CrowdTypeResource($this->whenLoaded('crowdType')),
|
||||
'company' => new CompanyResource($this->whenLoaded('company')),
|
||||
'tags' => $this->when(
|
||||
$this->user_id && $this->relationLoaded('user'),
|
||||
function () {
|
||||
$orgId = $this->event?->organisation_id;
|
||||
if (!$orgId || !$this->user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return UserOrganisationTagResource::collection(
|
||||
$this->user->organisationTags()
|
||||
->where('organisation_id', $orgId)
|
||||
->with('personTag')
|
||||
->get()
|
||||
);
|
||||
},
|
||||
[]
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
27
api/app/Http/Resources/Api/V1/PersonTagResource.php
Normal file
27
api/app/Http/Resources/Api/V1/PersonTagResource.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
final class PersonTagResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'organisation_id' => $this->organisation_id,
|
||||
'name' => $this->name,
|
||||
'category' => $this->category,
|
||||
'icon' => $this->icon,
|
||||
'color' => $this->color,
|
||||
'is_active' => $this->is_active,
|
||||
'sort_order' => $this->sort_order,
|
||||
'created_at' => $this->created_at->toIso8601String(),
|
||||
'updated_at' => $this->updated_at->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
final class UserOrganisationTagResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'person_tag' => [
|
||||
'id' => $this->personTag->id,
|
||||
'name' => $this->personTag->name,
|
||||
'category' => $this->personTag->category,
|
||||
'icon' => $this->personTag->icon,
|
||||
'color' => $this->personTag->color,
|
||||
],
|
||||
'source' => $this->source,
|
||||
'proficiency' => $this->proficiency,
|
||||
'notes' => $this->notes,
|
||||
'assigned_at' => $this->assigned_at->toIso8601String(),
|
||||
'assigned_by' => $this->when(
|
||||
$this->source === 'organiser_assigned' && $this->relationLoaded('assignedBy') && $this->assignedBy,
|
||||
fn () => ['id' => $this->assignedBy->id, 'name' => $this->assignedBy->name],
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
49
api/app/Models/PersonTag.php
Normal file
49
api/app/Models/PersonTag.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class PersonTag extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'category',
|
||||
'icon',
|
||||
'color',
|
||||
'is_active',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function organisation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organisation::class);
|
||||
}
|
||||
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeOrdered(Builder $query): Builder
|
||||
{
|
||||
return $query->orderBy('sort_order')->orderBy('name');
|
||||
}
|
||||
}
|
||||
@@ -63,4 +63,14 @@ final class User extends Authenticatable
|
||||
{
|
||||
return $this->hasMany(UserInvitation::class, 'invited_by_user_id');
|
||||
}
|
||||
|
||||
public function organisationTags(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserOrganisationTag::class);
|
||||
}
|
||||
|
||||
public function tagsForOrganisation(string $organisationId): HasMany
|
||||
{
|
||||
return $this->organisationTags()->where('organisation_id', $organisationId);
|
||||
}
|
||||
}
|
||||
|
||||
54
api/app/Models/UserOrganisationTag.php
Normal file
54
api/app/Models/UserOrganisationTag.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class UserOrganisationTag extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'organisation_id',
|
||||
'person_tag_id',
|
||||
'source',
|
||||
'assigned_by_user_id',
|
||||
'proficiency',
|
||||
'notes',
|
||||
'assigned_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'assigned_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function organisation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organisation::class);
|
||||
}
|
||||
|
||||
public function personTag(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PersonTag::class);
|
||||
}
|
||||
|
||||
public function assignedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'assigned_by_user_id');
|
||||
}
|
||||
}
|
||||
53
api/app/Policies/PersonTagPolicy.php
Normal file
53
api/app/Policies/PersonTagPolicy.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Organisation;
|
||||
use App\Models\PersonTag;
|
||||
use App\Models\User;
|
||||
|
||||
final class PersonTagPolicy
|
||||
{
|
||||
public function viewAny(User $user, Organisation $organisation): bool
|
||||
{
|
||||
return $user->hasRole('super_admin')
|
||||
|| $organisation->users()->where('user_id', $user->id)->exists();
|
||||
}
|
||||
|
||||
public function create(User $user, Organisation $organisation): bool
|
||||
{
|
||||
return $this->canManageOrganisation($user, $organisation);
|
||||
}
|
||||
|
||||
public function update(User $user, PersonTag $personTag, Organisation $organisation): bool
|
||||
{
|
||||
if ($personTag->organisation_id !== $organisation->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->canManageOrganisation($user, $organisation);
|
||||
}
|
||||
|
||||
public function delete(User $user, PersonTag $personTag, Organisation $organisation): bool
|
||||
{
|
||||
if ($personTag->organisation_id !== $organisation->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->canManageOrganisation($user, $organisation);
|
||||
}
|
||||
|
||||
private function canManageOrganisation(User $user, Organisation $organisation): bool
|
||||
{
|
||||
if ($user->hasRole('super_admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $organisation->users()
|
||||
->where('user_id', $user->id)
|
||||
->wherePivot('role', 'org_admin')
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
40
api/app/Policies/UserOrganisationTagPolicy.php
Normal file
40
api/app/Policies/UserOrganisationTagPolicy.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Organisation;
|
||||
use App\Models\User;
|
||||
use App\Models\UserOrganisationTag;
|
||||
|
||||
final class UserOrganisationTagPolicy
|
||||
{
|
||||
public function viewAny(User $user, Organisation $organisation): bool
|
||||
{
|
||||
return $user->hasRole('super_admin')
|
||||
|| $organisation->users()->where('user_id', $user->id)->exists();
|
||||
}
|
||||
|
||||
public function create(User $user, Organisation $organisation): bool
|
||||
{
|
||||
return $user->hasRole('super_admin')
|
||||
|| $organisation->users()->where('user_id', $user->id)->exists();
|
||||
}
|
||||
|
||||
public function delete(User $user, UserOrganisationTag $tag, Organisation $organisation): bool
|
||||
{
|
||||
if ($tag->organisation_id !== $organisation->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->hasRole('super_admin')
|
||||
|| $organisation->users()->where('user_id', $user->id)->exists();
|
||||
}
|
||||
|
||||
public function sync(User $user, Organisation $organisation): bool
|
||||
{
|
||||
return $user->hasRole('super_admin')
|
||||
|| $organisation->users()->where('user_id', $user->id)->exists();
|
||||
}
|
||||
}
|
||||
44
api/database/factories/PersonTagFactory.php
Normal file
44
api/database/factories/PersonTagFactory.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Organisation;
|
||||
use App\Models\PersonTag;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<PersonTag>
|
||||
*/
|
||||
final class PersonTagFactory extends Factory
|
||||
{
|
||||
protected $model = PersonTag::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'organisation_id' => Organisation::factory(),
|
||||
'name' => fake()->unique()->randomElement([
|
||||
'Tapper', 'EHBO', 'Kassa ervaring', 'Barhoofd', 'Beveiliging',
|
||||
'Duits', 'Engels', 'Frans', 'Spaans', 'Italiaans',
|
||||
'Heftruck', 'VCA', 'BHV', 'Geluidstechniek', 'Lichttechniek',
|
||||
'Podiummanager', 'Runner', 'Barista', 'Chauffeur', 'Fotograaf',
|
||||
'Gastheer', 'Logistiek', 'Catering', 'Decoratie', 'Schoonmaak',
|
||||
'Ticketing', 'Marketing', 'Social media', 'Communicatie', 'Grafisch ontwerp',
|
||||
'Video', 'Drone piloot', 'Horeca', 'Kassamedewerker', 'Backstage',
|
||||
'Artiestencoördinator', 'Parkeerplaats', 'Camping', 'Infopunt', 'Kinderanimatie',
|
||||
]),
|
||||
'category' => fake()->randomElement(['Vaardigheid', 'Taal', 'Certificaat', null]),
|
||||
'icon' => fake()->randomElement(['tabler-beer', 'tabler-first-aid-kit', 'tabler-language', null]),
|
||||
'color' => fake()->optional()->hexColor(),
|
||||
'is_active' => true,
|
||||
'sort_order' => fake()->numberBetween(0, 10),
|
||||
];
|
||||
}
|
||||
|
||||
public function inactive(): static
|
||||
{
|
||||
return $this->state(fn () => ['is_active' => false]);
|
||||
}
|
||||
}
|
||||
43
api/database/factories/UserOrganisationTagFactory.php
Normal file
43
api/database/factories/UserOrganisationTagFactory.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Organisation;
|
||||
use App\Models\PersonTag;
|
||||
use App\Models\User;
|
||||
use App\Models\UserOrganisationTag;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<UserOrganisationTag>
|
||||
*/
|
||||
final class UserOrganisationTagFactory extends Factory
|
||||
{
|
||||
protected $model = UserOrganisationTag::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => User::factory(),
|
||||
'organisation_id' => Organisation::factory(),
|
||||
'person_tag_id' => PersonTag::factory(),
|
||||
'source' => fake()->randomElement(['self_reported', 'organiser_assigned']),
|
||||
'assigned_by_user_id' => null,
|
||||
'proficiency' => fake()->optional()->randomElement(['beginner', 'experienced', 'expert']),
|
||||
'notes' => null,
|
||||
'assigned_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
public function selfReported(): static
|
||||
{
|
||||
return $this->state(fn () => ['source' => 'self_reported']);
|
||||
}
|
||||
|
||||
public function organiserAssigned(): static
|
||||
{
|
||||
return $this->state(fn () => ['source' => 'organiser_assigned']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('person_tags', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->foreignUlid('organisation_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name', 50);
|
||||
$table->string('category', 50)->nullable();
|
||||
$table->string('icon', 50)->nullable();
|
||||
$table->string('color', 7)->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->integer('sort_order')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['organisation_id', 'is_active', 'sort_order']);
|
||||
$table->unique(['organisation_id', 'name']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('person_tags');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('user_organisation_tags', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignUlid('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignUlid('organisation_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignUlid('person_tag_id')->constrained()->cascadeOnDelete();
|
||||
$table->enum('source', ['self_reported', 'organiser_assigned']);
|
||||
$table->foreignUlid('assigned_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->enum('proficiency', ['beginner', 'experienced', 'expert'])->nullable();
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamp('assigned_at');
|
||||
|
||||
$table->unique(['user_id', 'organisation_id', 'person_tag_id', 'source'], 'uot_user_org_tag_source_unique');
|
||||
$table->index(['user_id', 'organisation_id']);
|
||||
$table->index('person_tag_id');
|
||||
$table->index(['organisation_id', 'person_tag_id', 'proficiency'], 'uot_org_tag_proficiency_index');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user_organisation_tags');
|
||||
}
|
||||
};
|
||||
194
api/tests/Feature/PersonTag/PersonTagTest.php
Normal file
194
api/tests/Feature/PersonTag/PersonTagTest.php
Normal file
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\PersonTag;
|
||||
|
||||
use App\Models\Organisation;
|
||||
use App\Models\PersonTag;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RoleSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PersonTagTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $orgAdmin;
|
||||
private User $outsider;
|
||||
private Organisation $organisation;
|
||||
private Organisation $otherOrganisation;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seed(RoleSeeder::class);
|
||||
|
||||
$this->organisation = Organisation::factory()->create();
|
||||
$this->otherOrganisation = Organisation::factory()->create();
|
||||
|
||||
$this->orgAdmin = User::factory()->create();
|
||||
$this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']);
|
||||
|
||||
$this->outsider = User::factory()->create();
|
||||
$this->otherOrganisation->users()->attach($this->outsider, ['role' => 'org_admin']);
|
||||
}
|
||||
|
||||
public function test_index_returns_organisation_tags(): void
|
||||
{
|
||||
PersonTag::factory()->count(3)->sequence(
|
||||
['name' => 'Tag A'],
|
||||
['name' => 'Tag B'],
|
||||
['name' => 'Tag C'],
|
||||
)->create(['organisation_id' => $this->organisation->id]);
|
||||
PersonTag::factory()->inactive()->create(['organisation_id' => $this->organisation->id, 'name' => 'Tag D']);
|
||||
PersonTag::factory()->create(['organisation_id' => $this->otherOrganisation->id]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/person-tags");
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertCount(3, $response->json('data'));
|
||||
}
|
||||
|
||||
public function test_store_creates_tag(): void
|
||||
{
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/person-tags", [
|
||||
'name' => 'Tapper',
|
||||
'category' => 'Vaardigheid',
|
||||
'icon' => 'tabler-beer',
|
||||
'color' => '#10b981',
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJson(['data' => ['name' => 'Tapper', 'category' => 'Vaardigheid']]);
|
||||
|
||||
$this->assertDatabaseHas('person_tags', [
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'name' => 'Tapper',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_store_duplicate_name_returns_422(): void
|
||||
{
|
||||
PersonTag::factory()->create([
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'name' => 'Tapper',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/person-tags", [
|
||||
'name' => 'Tapper',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors('name');
|
||||
}
|
||||
|
||||
public function test_store_same_name_different_org_succeeds(): void
|
||||
{
|
||||
PersonTag::factory()->create([
|
||||
'organisation_id' => $this->otherOrganisation->id,
|
||||
'name' => 'Tapper',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/person-tags", [
|
||||
'name' => 'Tapper',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
}
|
||||
|
||||
public function test_update_tag(): void
|
||||
{
|
||||
$tag = PersonTag::factory()->create([
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'name' => 'Old Name',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/person-tags/{$tag->id}", [
|
||||
'name' => 'New Name',
|
||||
'color' => '#ff0000',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson(['data' => ['name' => 'New Name', 'color' => '#ff0000']]);
|
||||
}
|
||||
|
||||
public function test_destroy_deactivates_tag(): void
|
||||
{
|
||||
$tag = PersonTag::factory()->create([
|
||||
'organisation_id' => $this->organisation->id,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/person-tags/{$tag->id}");
|
||||
|
||||
$response->assertNoContent();
|
||||
$this->assertDatabaseHas('person_tags', [
|
||||
'id' => $tag->id,
|
||||
'is_active' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_cross_org_returns_403(): void
|
||||
{
|
||||
Sanctum::actingAs($this->outsider);
|
||||
|
||||
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/person-tags");
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_unauthenticated_returns_401(): void
|
||||
{
|
||||
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/person-tags");
|
||||
|
||||
$response->assertUnauthorized();
|
||||
}
|
||||
|
||||
public function test_categories_endpoint_returns_distinct_values(): void
|
||||
{
|
||||
PersonTag::factory()->create([
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'name' => 'Tapper',
|
||||
'category' => 'Vaardigheid',
|
||||
]);
|
||||
PersonTag::factory()->create([
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'name' => 'Duits',
|
||||
'category' => 'Taal',
|
||||
]);
|
||||
PersonTag::factory()->create([
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'name' => 'Kassa ervaring',
|
||||
'category' => 'Vaardigheid',
|
||||
]);
|
||||
PersonTag::factory()->create([
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'name' => 'Runner',
|
||||
'category' => null,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/person-tag-categories");
|
||||
|
||||
$response->assertOk();
|
||||
$categories = $response->json('data');
|
||||
$this->assertCount(2, $categories);
|
||||
$this->assertContains('Taal', $categories);
|
||||
$this->assertContains('Vaardigheid', $categories);
|
||||
}
|
||||
}
|
||||
420
api/tests/Feature/PersonTag/UserOrganisationTagTest.php
Normal file
420
api/tests/Feature/PersonTag/UserOrganisationTagTest.php
Normal file
@@ -0,0 +1,420 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\PersonTag;
|
||||
|
||||
use App\Models\CrowdType;
|
||||
use App\Models\Event;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\Person;
|
||||
use App\Models\PersonTag;
|
||||
use App\Models\User;
|
||||
use App\Models\UserOrganisationTag;
|
||||
use Database\Seeders\RoleSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class UserOrganisationTagTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $orgAdmin;
|
||||
private User $volunteer;
|
||||
private User $outsider;
|
||||
private Organisation $organisation;
|
||||
private Organisation $otherOrganisation;
|
||||
private Event $event;
|
||||
private CrowdType $crowdType;
|
||||
private PersonTag $tag1;
|
||||
private PersonTag $tag2;
|
||||
private PersonTag $tag3;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seed(RoleSeeder::class);
|
||||
|
||||
$this->organisation = Organisation::factory()->create();
|
||||
$this->otherOrganisation = Organisation::factory()->create();
|
||||
|
||||
$this->orgAdmin = User::factory()->create();
|
||||
$this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']);
|
||||
|
||||
$this->volunteer = User::factory()->create();
|
||||
$this->organisation->users()->attach($this->volunteer, ['role' => 'org_member']);
|
||||
|
||||
$this->outsider = User::factory()->create();
|
||||
$this->otherOrganisation->users()->attach($this->outsider, ['role' => 'org_admin']);
|
||||
|
||||
$this->event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
|
||||
$this->crowdType = CrowdType::factory()->create(['organisation_id' => $this->organisation->id]);
|
||||
|
||||
$this->tag1 = PersonTag::factory()->create([
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'name' => 'Tapper',
|
||||
]);
|
||||
$this->tag2 = PersonTag::factory()->create([
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'name' => 'EHBO',
|
||||
]);
|
||||
$this->tag3 = PersonTag::factory()->create([
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'name' => 'Duits',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_assign_tag_to_user(): void
|
||||
{
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/users/{$this->volunteer->id}/tags", [
|
||||
'person_tag_id' => $this->tag1->id,
|
||||
'source' => 'organiser_assigned',
|
||||
'proficiency' => 'experienced',
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonPath('data.person_tag.name', 'Tapper')
|
||||
->assertJsonPath('data.source', 'organiser_assigned')
|
||||
->assertJsonPath('data.proficiency', 'experienced')
|
||||
->assertJsonPath('data.assigned_by.id', $this->orgAdmin->id);
|
||||
|
||||
$this->assertDatabaseHas('user_organisation_tags', [
|
||||
'user_id' => $this->volunteer->id,
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'person_tag_id' => $this->tag1->id,
|
||||
'source' => 'organiser_assigned',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_assign_same_tag_different_source(): void
|
||||
{
|
||||
// Self-reported first
|
||||
UserOrganisationTag::create([
|
||||
'user_id' => $this->volunteer->id,
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'person_tag_id' => $this->tag1->id,
|
||||
'source' => 'self_reported',
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
// Organiser-assigned should also succeed
|
||||
$response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/users/{$this->volunteer->id}/tags", [
|
||||
'person_tag_id' => $this->tag1->id,
|
||||
'source' => 'organiser_assigned',
|
||||
'proficiency' => 'expert',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
$this->assertDatabaseCount('user_organisation_tags', 2);
|
||||
}
|
||||
|
||||
public function test_assign_duplicate_same_source_returns_422(): void
|
||||
{
|
||||
UserOrganisationTag::create([
|
||||
'user_id' => $this->volunteer->id,
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'person_tag_id' => $this->tag1->id,
|
||||
'source' => 'self_reported',
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/users/{$this->volunteer->id}/tags", [
|
||||
'person_tag_id' => $this->tag1->id,
|
||||
'source' => 'self_reported',
|
||||
]);
|
||||
|
||||
// Unique constraint violation → 503 (database error) or we handle it
|
||||
// The DB unique constraint will catch this
|
||||
$response->assertStatus(503);
|
||||
}
|
||||
|
||||
public function test_sync_self_reported_tags(): void
|
||||
{
|
||||
// Start with tag1 and tag2 as self_reported
|
||||
UserOrganisationTag::create([
|
||||
'user_id' => $this->volunteer->id,
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'person_tag_id' => $this->tag1->id,
|
||||
'source' => 'self_reported',
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
UserOrganisationTag::create([
|
||||
'user_id' => $this->volunteer->id,
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'person_tag_id' => $this->tag2->id,
|
||||
'source' => 'self_reported',
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
// Sync to tag2 and tag3 (remove tag1, keep tag2, add tag3)
|
||||
$response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/users/{$this->volunteer->id}/tags/sync", [
|
||||
'tag_ids' => [$this->tag2->id, $this->tag3->id],
|
||||
'source' => 'self_reported',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
// tag1 self_reported should be gone
|
||||
$this->assertDatabaseMissing('user_organisation_tags', [
|
||||
'user_id' => $this->volunteer->id,
|
||||
'person_tag_id' => $this->tag1->id,
|
||||
'source' => 'self_reported',
|
||||
]);
|
||||
|
||||
// tag2 and tag3 self_reported should exist
|
||||
$this->assertDatabaseHas('user_organisation_tags', [
|
||||
'user_id' => $this->volunteer->id,
|
||||
'person_tag_id' => $this->tag2->id,
|
||||
'source' => 'self_reported',
|
||||
]);
|
||||
$this->assertDatabaseHas('user_organisation_tags', [
|
||||
'user_id' => $this->volunteer->id,
|
||||
'person_tag_id' => $this->tag3->id,
|
||||
'source' => 'self_reported',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_sync_does_not_remove_organiser_assigned(): void
|
||||
{
|
||||
// Organiser-assigned tag
|
||||
UserOrganisationTag::create([
|
||||
'user_id' => $this->volunteer->id,
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'person_tag_id' => $this->tag1->id,
|
||||
'source' => 'organiser_assigned',
|
||||
'assigned_by_user_id' => $this->orgAdmin->id,
|
||||
'proficiency' => 'expert',
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
|
||||
// Self-reported tag
|
||||
UserOrganisationTag::create([
|
||||
'user_id' => $this->volunteer->id,
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'person_tag_id' => $this->tag2->id,
|
||||
'source' => 'self_reported',
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
// Sync self_reported to only tag3 → should remove tag2 self_reported, keep tag1 organiser_assigned
|
||||
$response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/users/{$this->volunteer->id}/tags/sync", [
|
||||
'tag_ids' => [$this->tag3->id],
|
||||
'source' => 'self_reported',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
// Organiser-assigned tag1 must still exist
|
||||
$this->assertDatabaseHas('user_organisation_tags', [
|
||||
'user_id' => $this->volunteer->id,
|
||||
'person_tag_id' => $this->tag1->id,
|
||||
'source' => 'organiser_assigned',
|
||||
'proficiency' => 'expert',
|
||||
]);
|
||||
|
||||
// Self-reported tag2 should be gone
|
||||
$this->assertDatabaseMissing('user_organisation_tags', [
|
||||
'user_id' => $this->volunteer->id,
|
||||
'person_tag_id' => $this->tag2->id,
|
||||
'source' => 'self_reported',
|
||||
]);
|
||||
|
||||
// Self-reported tag3 should exist
|
||||
$this->assertDatabaseHas('user_organisation_tags', [
|
||||
'user_id' => $this->volunteer->id,
|
||||
'person_tag_id' => $this->tag3->id,
|
||||
'source' => 'self_reported',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_remove_tag_assignment(): void
|
||||
{
|
||||
$assignment = UserOrganisationTag::create([
|
||||
'user_id' => $this->volunteer->id,
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'person_tag_id' => $this->tag1->id,
|
||||
'source' => 'self_reported',
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/users/{$this->volunteer->id}/tags/{$assignment->id}");
|
||||
|
||||
$response->assertNoContent();
|
||||
$this->assertDatabaseMissing('user_organisation_tags', ['id' => $assignment->id]);
|
||||
}
|
||||
|
||||
public function test_person_list_includes_tags(): void
|
||||
{
|
||||
$person = Person::factory()->create([
|
||||
'event_id' => $this->event->id,
|
||||
'crowd_type_id' => $this->crowdType->id,
|
||||
'user_id' => $this->volunteer->id,
|
||||
]);
|
||||
|
||||
UserOrganisationTag::create([
|
||||
'user_id' => $this->volunteer->id,
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'person_tag_id' => $this->tag1->id,
|
||||
'source' => 'self_reported',
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->getJson("/api/v1/events/{$this->event->id}/persons/{$person->id}");
|
||||
|
||||
$response->assertOk();
|
||||
$tags = $response->json('data.tags');
|
||||
$this->assertNotEmpty($tags);
|
||||
$this->assertEquals('Tapper', $tags[0]['person_tag']['name']);
|
||||
}
|
||||
|
||||
public function test_filter_persons_by_tag(): void
|
||||
{
|
||||
$personWithTag = Person::factory()->create([
|
||||
'event_id' => $this->event->id,
|
||||
'crowd_type_id' => $this->crowdType->id,
|
||||
'user_id' => $this->volunteer->id,
|
||||
]);
|
||||
|
||||
$personWithoutTag = Person::factory()->create([
|
||||
'event_id' => $this->event->id,
|
||||
'crowd_type_id' => $this->crowdType->id,
|
||||
]);
|
||||
|
||||
UserOrganisationTag::create([
|
||||
'user_id' => $this->volunteer->id,
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'person_tag_id' => $this->tag1->id,
|
||||
'source' => 'self_reported',
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->getJson("/api/v1/events/{$this->event->id}/persons?tag={$this->tag1->id}");
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertCount(1, $response->json('data'));
|
||||
$this->assertEquals($personWithTag->id, $response->json('data.0.id'));
|
||||
}
|
||||
|
||||
public function test_filter_persons_by_multiple_tags(): void
|
||||
{
|
||||
// Volunteer has both tag1 and tag2
|
||||
$personBothTags = Person::factory()->create([
|
||||
'event_id' => $this->event->id,
|
||||
'crowd_type_id' => $this->crowdType->id,
|
||||
'user_id' => $this->volunteer->id,
|
||||
]);
|
||||
|
||||
// Another user with only tag1
|
||||
$otherUser = User::factory()->create();
|
||||
$this->organisation->users()->attach($otherUser, ['role' => 'org_member']);
|
||||
$personOneTag = Person::factory()->create([
|
||||
'event_id' => $this->event->id,
|
||||
'crowd_type_id' => $this->crowdType->id,
|
||||
'user_id' => $otherUser->id,
|
||||
]);
|
||||
|
||||
UserOrganisationTag::create([
|
||||
'user_id' => $this->volunteer->id,
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'person_tag_id' => $this->tag1->id,
|
||||
'source' => 'self_reported',
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
UserOrganisationTag::create([
|
||||
'user_id' => $this->volunteer->id,
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'person_tag_id' => $this->tag2->id,
|
||||
'source' => 'self_reported',
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
UserOrganisationTag::create([
|
||||
'user_id' => $otherUser->id,
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'person_tag_id' => $this->tag1->id,
|
||||
'source' => 'self_reported',
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
// AND filter: must have both tag1 AND tag2
|
||||
$response = $this->getJson("/api/v1/events/{$this->event->id}/persons?tags={$this->tag1->id},{$this->tag2->id}");
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertCount(1, $response->json('data'));
|
||||
$this->assertEquals($personBothTags->id, $response->json('data.0.id'));
|
||||
}
|
||||
|
||||
public function test_person_without_user_id_has_no_tags(): void
|
||||
{
|
||||
$person = Person::factory()->create([
|
||||
'event_id' => $this->event->id,
|
||||
'crowd_type_id' => $this->crowdType->id,
|
||||
'user_id' => null,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->getJson("/api/v1/events/{$this->event->id}/persons/{$person->id}");
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertEquals([], $response->json('data.tags'));
|
||||
}
|
||||
|
||||
public function test_index_returns_user_tags(): void
|
||||
{
|
||||
UserOrganisationTag::create([
|
||||
'user_id' => $this->volunteer->id,
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'person_tag_id' => $this->tag1->id,
|
||||
'source' => 'self_reported',
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
UserOrganisationTag::create([
|
||||
'user_id' => $this->volunteer->id,
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'person_tag_id' => $this->tag2->id,
|
||||
'source' => 'organiser_assigned',
|
||||
'assigned_by_user_id' => $this->orgAdmin->id,
|
||||
'proficiency' => 'expert',
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/users/{$this->volunteer->id}/tags");
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertCount(2, $response->json('data'));
|
||||
}
|
||||
|
||||
public function test_cross_org_tag_assignment_returns_403(): void
|
||||
{
|
||||
Sanctum::actingAs($this->outsider);
|
||||
|
||||
$response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/users/{$this->volunteer->id}/tags", [
|
||||
'person_tag_id' => $this->tag1->id,
|
||||
'source' => 'organiser_assigned',
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user