added "members" for an event that help the admins to moderate. members must be invited via email.

This commit is contained in:
Codex Agent
2025-11-09 22:24:40 +01:00
parent 082b78cd43
commit 7ec3db9c59
23 changed files with 836 additions and 101 deletions

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

View File

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

View File

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

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

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

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

View File

@@ -107,6 +107,11 @@ class Event extends Model
return $this->hasMany(EventJoinToken::class);
}
public function members(): HasMany
{
return $this->hasMany(EventMember::class);
}
public function hasActivePackage(): bool
{
return $this->eventPackage && $this->eventPackage->isActive();

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** @property array<string,mixed>|null $permissions */
class EventMember extends Model
{
/** @use HasFactory<\Database\Factories\EventMemberFactory> */
use HasFactory;
protected $fillable = [
'tenant_id',
'event_id',
'user_id',
'invited_by',
'name',
'email',
'role',
'status',
'invite_token',
'invited_at',
'joined_at',
'last_activity_at',
'permissions',
];
protected $casts = [
'invited_at' => 'datetime',
'joined_at' => 'datetime',
'last_activity_at' => 'datetime',
'permissions' => 'array',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function invitedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'invited_by');
}
}

View File

@@ -49,6 +49,11 @@ class Tenant extends Model
);
}
public function members(): HasMany
{
return $this->hasMany(EventMember::class);
}
public function purchases(): HasMany
{
return $this->hasMany(PackagePurchase::class);

View File

@@ -3,21 +3,22 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Filament\Models\Contracts\FilamentUser;
use Filament\Models\Contracts\HasName;
use Filament\Models\Contracts\HasTenants as FilamentHasTenants;
use Filament\Panel;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
use Filament\Models\Contracts\FilamentUser;
use Filament\Models\Contracts\HasTenants as FilamentHasTenants;
use Filament\Panel;
use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Filament\Models\Contracts\HasName;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable implements MustVerifyEmail, HasName, FilamentUser, FilamentHasTenants
class User extends Authenticatable implements FilamentHasTenants, FilamentUser, HasName, MustVerifyEmail
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasApiTokens, HasFactory, Notifiable;
@@ -38,6 +39,7 @@ class User extends Authenticatable implements MustVerifyEmail, HasName, Filament
'address',
'phone',
'role',
'tenant_id',
'pending_purchase',
];
@@ -77,7 +79,7 @@ class User extends Authenticatable implements MustVerifyEmail, HasName, Filament
if (isset($credentials['login'])) {
$login = $credentials['login'];
$query->where('email', $login)
->orWhere('username', $login);
->orWhere('username', $login);
} else {
foreach ($this->getAuthIdentifiers() as $key => $value) {
$query->where($key, $value);
@@ -93,15 +95,16 @@ class User extends Authenticatable implements MustVerifyEmail, HasName, Filament
protected function fullName(): Attribute
{
return Attribute::make(
get: fn () => trim(($this->first_name ?? '') . ' ' . ($this->last_name ?? '')) ?: $this->name,
get: fn () => trim(($this->first_name ?? '').' '.($this->last_name ?? '')) ?: $this->name,
);
}
public function getFilamentName(): string
{
if ($this->first_name && $this->last_name) {
return trim($this->first_name . ' ' . $this->last_name);
return trim($this->first_name.' '.$this->last_name);
}
return $this->username ?? $this->email ?? 'Unnamed User';
}
@@ -138,7 +141,7 @@ class User extends Authenticatable implements MustVerifyEmail, HasName, Filament
return (int) $tenant->getKey() === (int) $ownedTenant->getKey();
}
public function getTenants(Panel $panel): array | Collection
public function getTenants(Panel $panel): array|Collection
{
if ($this->role === 'super_admin') {
return Tenant::query()->orderBy('name')->get();
@@ -148,4 +151,9 @@ class User extends Authenticatable implements MustVerifyEmail, HasName, Filament
return $tenant ? collect([$tenant]) : collect();
}
public function eventMemberships(): HasMany
{
return $this->hasMany(EventMember::class);
}
}

View File

@@ -24,7 +24,7 @@ class TenantAuth
}
$user = $request->user();
if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) {
if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'member'], true)) {
if ($user->role !== 'super_admin' || (int) $user->tenant_id === (int) $tenantId) {
return $user;
}
@@ -32,7 +32,7 @@ class TenantAuth
$user = User::query()
->where('tenant_id', $tenantId)
->whereIn('role', ['tenant_admin', 'admin'])
->whereIn('role', ['tenant_admin', 'admin', 'member'])
->orderByDesc('email_verified_at')
->orderBy('id')
->first();