added "members" for an event that help the admins to moderate. members must be invited via email.
This commit is contained in:
195
app/Http/Controllers/Api/Tenant/EventMemberController.php
Normal file
195
app/Http/Controllers/Api/Tenant/EventMemberController.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Tenant\EventMemberInviteRequest;
|
||||
use App\Http\Resources\Tenant\EventMemberResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventMember;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class EventMemberController extends Controller
|
||||
{
|
||||
public function index(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
$this->assertEventTenant($request, $event);
|
||||
|
||||
/** @var LengthAwarePaginator $members */
|
||||
$members = $event->members()
|
||||
->orderByDesc('created_at')
|
||||
->paginate($request->integer('per_page', 25));
|
||||
|
||||
return EventMemberResource::collection($members)->response();
|
||||
}
|
||||
|
||||
public function store(EventMemberInviteRequest $request, Event $event): JsonResponse
|
||||
{
|
||||
$this->assertEventTenant($request, $event);
|
||||
|
||||
$data = $request->validated();
|
||||
$tenant = $this->resolveTenantFromRequest($request);
|
||||
|
||||
if (! $tenant) {
|
||||
throw ValidationException::withMessages([
|
||||
'tenant_id' => __('Tenant context missing for invitation.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$email = Str::lower($data['email']);
|
||||
$role = $data['role'];
|
||||
$name = $data['name'] ?? null;
|
||||
|
||||
$user = $this->findOrCreateUser($tenant, $email, $name, $role);
|
||||
|
||||
if ((int) $user->tenant_id !== (int) $tenant->id) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('Dieser Benutzer gehört bereits zu einem anderen Mandanten.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$existing = EventMember::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('email', $email)
|
||||
->first();
|
||||
|
||||
$member = EventMember::updateOrCreate(
|
||||
[
|
||||
'event_id' => $event->id,
|
||||
'email' => $email,
|
||||
],
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'invited_by' => $request->user()?->id,
|
||||
'name' => $name ?? $user->name ?? $email,
|
||||
'role' => $role,
|
||||
'status' => $user->email_verified_at ? 'active' : 'invited',
|
||||
'invite_token' => $this->ensureInviteToken($event, $email),
|
||||
'invited_at' => $existing?->invited_at ?? now(),
|
||||
'joined_at' => $user->email_verified_at ? ($existing?->joined_at ?? now()) : null,
|
||||
'permissions' => $this->defaultPermissionsForRole($role),
|
||||
]
|
||||
);
|
||||
|
||||
$member->refresh();
|
||||
|
||||
return (new EventMemberResource($member))->response()->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Event $event, EventMember $member): JsonResponse
|
||||
{
|
||||
$this->assertEventTenant($request, $event);
|
||||
|
||||
if ((int) $member->event_id !== (int) $event->id) {
|
||||
throw ValidationException::withMessages([
|
||||
'member_id' => __('Dieses Mitglied gehört nicht zu diesem Event.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$member->delete();
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
private function assertEventTenant(Request $request, Event $event): void
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
if ($tenantId === null || (int) $event->tenant_id !== (int) $tenantId) {
|
||||
abort(403, 'Event belongs to a different tenant.');
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveTenantFromRequest(Request $request): ?Tenant
|
||||
{
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
return $tenantId ? Tenant::query()->find($tenantId) : null;
|
||||
}
|
||||
|
||||
private function findOrCreateUser(Tenant $tenant, string $email, ?string $name, string $role): User
|
||||
{
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
|
||||
if (! $user) {
|
||||
$user = new User;
|
||||
$user->email = $email;
|
||||
$user->password = Hash::make(Str::random(32));
|
||||
}
|
||||
|
||||
if ($user->tenant_id && (int) $user->tenant_id !== (int) $tenant->id && $user->role !== 'super_admin') {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('Dieser Benutzer ist einem anderen Mandanten zugeordnet.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$user->tenant_id = $tenant->id;
|
||||
|
||||
if ($role === 'tenant_admin' && $user->role !== 'super_admin') {
|
||||
$user->role = 'tenant_admin';
|
||||
} elseif (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) {
|
||||
$user->role = 'member';
|
||||
}
|
||||
|
||||
if ($name) {
|
||||
$user->name = $name;
|
||||
[$first, $last] = $this->splitName($name);
|
||||
$user->first_name = $first;
|
||||
$user->last_name = $last;
|
||||
}
|
||||
|
||||
if (! $user->exists) {
|
||||
$user->email_verified_at = null;
|
||||
}
|
||||
|
||||
$user->save();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function splitName(string $name): array
|
||||
{
|
||||
$parts = preg_split('/\s+/', trim($name));
|
||||
$first = Arr::first($parts) ?? $name;
|
||||
$last = count($parts) > 1 ? Arr::last($parts) : null;
|
||||
|
||||
return [$first, $last];
|
||||
}
|
||||
|
||||
private function ensureInviteToken(Event $event, string $email): string
|
||||
{
|
||||
$existing = EventMember::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('email', $email)
|
||||
->value('invite_token');
|
||||
|
||||
return $existing ?? Str::random(40);
|
||||
}
|
||||
|
||||
private function defaultPermissionsForRole(string $role): array
|
||||
{
|
||||
if ($role === 'tenant_admin') {
|
||||
return ['*'];
|
||||
}
|
||||
|
||||
return [
|
||||
'photos:moderate',
|
||||
'tasks:manage',
|
||||
'join-tokens:manage',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\TenantAdminTokenRequest;
|
||||
use App\Models\EventMember;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -33,11 +34,7 @@ class TenantAdminTokenController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'login' => [trans('auth.not_authorized')],
|
||||
]);
|
||||
}
|
||||
$this->ensureUserCanAccessPanel($user);
|
||||
|
||||
if ($user->email_verified_at === null) {
|
||||
throw ValidationException::withMessages([
|
||||
@@ -162,12 +159,7 @@ class TenantAdminTokenController extends Controller
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
if (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) {
|
||||
return response()->json([
|
||||
'error' => 'forbidden',
|
||||
'message' => trans('auth.not_authorized'),
|
||||
], 403);
|
||||
}
|
||||
$this->ensureUserCanAccessPanel($user);
|
||||
|
||||
if ($user->email_verified_at === null) {
|
||||
return response()->json([
|
||||
@@ -197,12 +189,16 @@ class TenantAdminTokenController extends Controller
|
||||
*/
|
||||
private function resolveTokenAbilities(User $user): array
|
||||
{
|
||||
$abilities = ['tenant-admin'];
|
||||
$abilities = ['tenant-member'];
|
||||
|
||||
if ($user->tenant_id) {
|
||||
$abilities[] = 'tenant:'.$user->tenant_id;
|
||||
}
|
||||
|
||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) {
|
||||
$abilities[] = 'tenant-admin';
|
||||
}
|
||||
|
||||
if ($user->role === 'super_admin') {
|
||||
$abilities[] = 'super-admin';
|
||||
}
|
||||
@@ -222,4 +218,35 @@ class TenantAdminTokenController extends Controller
|
||||
|
||||
return [$token->plainTextToken, $abilities];
|
||||
}
|
||||
|
||||
private function ensureUserCanAccessPanel(User $user): void
|
||||
{
|
||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($user->role === 'member' && $this->userHasCollaboratorMembership($user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'login' => [trans('auth.not_authorized')],
|
||||
]);
|
||||
}
|
||||
|
||||
private function userHasCollaboratorMembership(User $user): bool
|
||||
{
|
||||
if (! $user->tenant_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return EventMember::query()
|
||||
->where('tenant_id', $user->tenant_id)
|
||||
->where(function ($query) use ($user) {
|
||||
$query->where('user_id', $user->id)
|
||||
->orWhere('email', $user->email);
|
||||
})
|
||||
->whereIn('status', ['active', 'invited'])
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\ApiError;
|
||||
use Closure;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -30,12 +31,12 @@ class EnsureTenantAdminToken
|
||||
return $this->unauthorizedResponse('Missing personal access token context.');
|
||||
}
|
||||
|
||||
if (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) {
|
||||
return $this->forbiddenResponse('Only tenant administrators may access this resource.');
|
||||
if (! in_array($user->role, $this->allowedRoles(), true)) {
|
||||
return $this->forbiddenResponse($this->forbiddenRoleMessage());
|
||||
}
|
||||
|
||||
if (! $accessToken->can('tenant-admin')) {
|
||||
return $this->forbiddenResponse('Access token does not include the tenant-admin ability.');
|
||||
if (! $this->hasRequiredAbilities($accessToken, $user)) {
|
||||
return $this->forbiddenResponse($this->abilityErrorMessage());
|
||||
}
|
||||
|
||||
/** @var Tenant|null $tenant */
|
||||
@@ -90,6 +91,40 @@ class EnsureTenantAdminToken
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function allowedRoles(): array
|
||||
{
|
||||
return ['tenant_admin', 'super_admin', 'admin'];
|
||||
}
|
||||
|
||||
protected function forbiddenRoleMessage(): string
|
||||
{
|
||||
return 'Only tenant administrators may access this resource.';
|
||||
}
|
||||
|
||||
protected function requiredAbilitiesForUser(User $user): array
|
||||
{
|
||||
return ['tenant-admin'];
|
||||
}
|
||||
|
||||
protected function abilityErrorMessage(): string
|
||||
{
|
||||
return 'Access token does not include the tenant-admin ability.';
|
||||
}
|
||||
|
||||
protected function hasRequiredAbilities(PersonalAccessToken $accessToken, User $user): bool
|
||||
{
|
||||
foreach ($this->requiredAbilitiesForUser($user) as $ability) {
|
||||
if (! $accessToken->can($ability)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function resolveRequestedTenantId(Request $request): ?int
|
||||
{
|
||||
$routeTenant = $request->route('tenant');
|
||||
|
||||
33
app/Http/Middleware/EnsureTenantCollaboratorToken.php
Normal file
33
app/Http/Middleware/EnsureTenantCollaboratorToken.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
|
||||
class EnsureTenantCollaboratorToken extends EnsureTenantAdminToken
|
||||
{
|
||||
protected function allowedRoles(): array
|
||||
{
|
||||
return ['tenant_admin', 'super_admin', 'admin', 'member'];
|
||||
}
|
||||
|
||||
protected function forbiddenRoleMessage(): string
|
||||
{
|
||||
return 'Only tenant collaborators may access this resource.';
|
||||
}
|
||||
|
||||
protected function abilityErrorMessage(): string
|
||||
{
|
||||
return 'Access token does not include the tenant-member ability.';
|
||||
}
|
||||
|
||||
protected function hasRequiredAbilities(PersonalAccessToken $accessToken, User $user): bool
|
||||
{
|
||||
if ($accessToken->can('tenant-admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $accessToken->can('tenant-member');
|
||||
}
|
||||
}
|
||||
30
app/Http/Requests/Tenant/EventMemberInviteRequest.php
Normal file
30
app/Http/Requests/Tenant/EventMemberInviteRequest.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class EventMemberInviteRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'email:rfc', 'max:255'],
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'role' => ['required', 'string', 'in:tenant_admin,member'],
|
||||
];
|
||||
}
|
||||
}
|
||||
32
app/Http/Resources/Tenant/EventMemberResource.php
Normal file
32
app/Http/Resources/Tenant/EventMemberResource.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Tenant;
|
||||
|
||||
use App\Models\EventMember;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/** @mixin EventMember */
|
||||
class EventMemberResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name ?? $this->user?->full_name ?? $this->email,
|
||||
'email' => $this->email,
|
||||
'role' => $this->role,
|
||||
'status' => $this->status,
|
||||
'joined_at' => $this->joined_at,
|
||||
'invited_at' => $this->invited_at,
|
||||
'avatar_url' => $this->user?->profile_photo_url ?? null,
|
||||
'permissions' => $this->permissions,
|
||||
'user_id' => $this->user_id,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user