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:
2026-04-10 11:15:43 +02:00
parent 5dbe7a254e
commit d37a45b028
21 changed files with 1375 additions and 1 deletions

View File

@@ -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));
}

View 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]);
}
}

View File

@@ -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);
}
}

View 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'],
];
}
}

View File

@@ -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'],
];
}
}

View File

@@ -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'],
];
}
}

View 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'],
];
}
}

View File

@@ -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()
);
},
[]
),
];
}
}

View 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(),
];
}
}

View File

@@ -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],
),
];
}
}

View 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');
}
}

View File

@@ -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);
}
}

View 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');
}
}

View 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();
}
}

View 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();
}
}