diff --git a/Dockerfile b/Dockerfile index e35627daf..c2df5eb17 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ WORKDIR /usr/src/trwl RUN composer install --ignore-platform-reqs --no-interaction --no-progress --no-suggest --optimize-autoloader RUN php artisan optimize -FROM php:8.3.9-apache +FROM php:8.3.10-apache ENV APACHE_DOCUMENT_ROOT=/var/www/html/public RUN apt update && \ diff --git a/app/Console/Commands/DatabaseCleaner/DatabaseCleaner.php b/app/Console/Commands/DatabaseCleaner/DatabaseCleaner.php index fc227ca59..96ee599b5 100644 --- a/app/Console/Commands/DatabaseCleaner/DatabaseCleaner.php +++ b/app/Console/Commands/DatabaseCleaner/DatabaseCleaner.php @@ -15,6 +15,7 @@ public function handle(): int { $this->call(Polylines::class); $this->call(PolylinesBrouter::class); $this->call(User::class); + $this->call(TrustedUser::class); $this->call(Trips::class); $this->call('queue-monitor:purge', ['--beforeDays' => 7]); diff --git a/app/Console/Commands/DatabaseCleaner/TrustedUser.php b/app/Console/Commands/DatabaseCleaner/TrustedUser.php new file mode 100644 index 000000000..de493fd0d --- /dev/null +++ b/app/Console/Commands/DatabaseCleaner/TrustedUser.php @@ -0,0 +1,18 @@ +delete(); + $this->info($affectedRows . ' expired trusted users deleted.'); + return 0; + } +} diff --git a/app/Enum/User/FriendCheckinSetting.php b/app/Enum/User/FriendCheckinSetting.php new file mode 100644 index 000000000..a5a78a395 --- /dev/null +++ b/app/Enum/User/FriendCheckinSetting.php @@ -0,0 +1,20 @@ +user(); + } + return User::findOrFail($userIdOrSelf); + } } diff --git a/app/Http/Controllers/API/v1/SettingsController.php b/app/Http/Controllers/API/v1/SettingsController.php index 84085bd1e..61eff27e7 100644 --- a/app/Http/Controllers/API/v1/SettingsController.php +++ b/app/Http/Controllers/API/v1/SettingsController.php @@ -5,6 +5,7 @@ use App\Enum\MapProvider; use App\Enum\MastodonVisibility; use App\Enum\StatusVisibility; +use App\Enum\User\FriendCheckinSetting; use App\Exceptions\RateLimitExceededException; use App\Http\Controllers\Backend\SettingsController as BackendSettingsController; use App\Http\Resources\UserProfileSettingsResource; @@ -63,39 +64,6 @@ public function updateMail(Request $request): UserProfileSettingsResource|JsonRe } } - public function resendMail(): void { - try { - auth()->user()->sendEmailVerificationNotification(); - $this->sendResponse('', 204); - } catch (RateLimitExceededException) { - $this->sendError(error: __('email.verification.too-many-requests'), code: 429); - } - } - - /** - * @throws ValidationException - */ - public function updatePassword(Request $request): UserProfileSettingsResource|JsonResponse { - $userHasPassword = auth()->user()->password !== null; - - $validated = $request->validate([ - 'currentPassword' => [Rule::requiredIf($userHasPassword)], - 'password' => ['required', 'string', 'min:8', 'confirmed'] - ]); - - if ($userHasPassword && !Hash::check($validated['currentPassword'], auth()->user()->password)) { - throw ValidationException::withMessages([__('controller.user.password-wrong')]); - } - - $validated['password'] = Hash::make($validated['password']); - - try { - return new UserProfileSettingsResource(BackendSettingsController::updateSettings($validated)); - } catch (RateLimitExceededException) { - return $this->sendError(error: __('email.verification.too-many-requests'), code: 400); - } - } - /** * @OA\Put( * path="/settings/profile", @@ -128,6 +96,13 @@ public function updatePassword(Request $request): UserProfileSettingsResource|Js * type="string", * nullable=true, * @OA\Schema(ref="#/components/schemas/MapProvider") + * ), + * @OA\Property( + * property="friendCheckin", + * type="string", + * nullable=true, + * @OA\Schema(ref="#/components/schemas/FriendCheckinSetting"), + * example="forbidden" * ) * ) * ), @@ -148,10 +123,9 @@ public function updatePassword(Request $request): UserProfileSettingsResource|Js */ public function updateSettings(Request $request): UserProfileSettingsResource|JsonResponse { $validated = $request->validate([ - 'username' => ['required', - 'string', - 'max:25', - 'regex:/^[a-zA-Z0-9_]*$/'], + 'username' => [ + 'required', 'string', 'max:25', 'regex:/^[a-zA-Z0-9_]*$/' + ], 'displayName' => ['required', 'string', 'max:50'], 'privateProfile' => ['boolean', 'nullable'], 'preventIndex' => ['boolean', 'nullable'], @@ -165,8 +139,42 @@ public function updateSettings(Request $request): UserProfileSettingsResource|Js new Enum(MastodonVisibility::class), ], 'mapProvider' => ['nullable', new Enum(MapProvider::class)], + 'friendCheckin' => ['nullable', new Enum(FriendCheckinSetting::class)] + ]); + + try { + return new UserProfileSettingsResource(BackendSettingsController::updateSettings($validated)); + } catch (RateLimitExceededException) { + return $this->sendError(error: __('email.verification.too-many-requests'), code: 400); + } + } + + public function resendMail(): void { + try { + auth()->user()->sendEmailVerificationNotification(); + $this->sendResponse('', 204); + } catch (RateLimitExceededException) { + $this->sendError(error: __('email.verification.too-many-requests'), code: 429); + } + } + + /** + * @throws ValidationException + */ + public function updatePassword(Request $request): UserProfileSettingsResource|JsonResponse { + $userHasPassword = auth()->user()->password !== null; + + $validated = $request->validate([ + 'currentPassword' => [Rule::requiredIf($userHasPassword)], + 'password' => ['required', 'string', 'min:8', 'confirmed'] ]); + if ($userHasPassword && !Hash::check($validated['currentPassword'], auth()->user()->password)) { + throw ValidationException::withMessages([__('controller.user.password-wrong')]); + } + + $validated['password'] = Hash::make($validated['password']); + try { return new UserProfileSettingsResource(BackendSettingsController::updateSettings($validated)); } catch (RateLimitExceededException) { diff --git a/app/Http/Controllers/API/v1/TransportController.php b/app/Http/Controllers/API/v1/TransportController.php index 41fc3b34a..eaa1f0874 100644 --- a/app/Http/Controllers/API/v1/TransportController.php +++ b/app/Http/Controllers/API/v1/TransportController.php @@ -2,7 +2,6 @@ namespace App\Http\Controllers\API\v1; -use App\Dto\Internal\CheckinSuccessDto; use App\Dto\Transport\Station as StationDto; use App\Enum\Business; use App\Enum\StatusVisibility; @@ -10,20 +9,18 @@ use App\Exceptions\Checkin\AlreadyCheckedInException; use App\Exceptions\CheckInCollisionException; use App\Exceptions\HafasException; -use App\Exceptions\NotConnectedException; use App\Exceptions\StationNotOnTripException; -use App\Http\Controllers\Backend\Transport\HomeController; use App\Http\Controllers\Backend\Transport\StationController; use App\Http\Controllers\Backend\Transport\TrainCheckinController; use App\Http\Controllers\HafasController; use App\Http\Controllers\TransportController as TransportBackend; use App\Http\Resources\CheckinSuccessResource; use App\Http\Resources\StationResource; -use App\Http\Resources\StatusResource; use App\Http\Resources\TripResource; use App\Hydrators\CheckinRequestHydrator; -use App\Models\Event; use App\Models\Station; +use App\Models\User; +use App\Notifications\YouHaveBeenCheckedIn; use Carbon\Carbon; use Exception; use Illuminate\Database\Eloquent\ModelNotFoundException; @@ -332,9 +329,9 @@ public function getNextStationByCoordinates(Request $request): JsonResponse { /** * @OA\Post( * path="/trains/checkin", - * operationId="createTrainCheckin", + * operationId="createCheckin", * tags={"Checkin"}, - * summary="Create a checkin", + * summary="Check in to a trip.", * @OA\RequestBody( * required=true, * @OA\JsonContent(ref="#/components/schemas/CheckinRequestBody") @@ -345,11 +342,11 @@ public function getNextStationByCoordinates(Request $request): JsonResponse { * @OA\JsonContent(ref="#/components/schemas/CheckinSuccessResource") * ), * @OA\Response(response=400, description="Bad request"), - * @OA\Response(response=409, description="Checkin collision"), * @OA\Response(response=401, description="Unauthorized"), + * @OA\Response(response=403, description="Forbidden", @OA\JsonContent(ref="#/components/schemas/CheckinForbiddenWithUsersResponse")), + * @OA\Response(response=409, description="Checkin collision"), * security={ * {"passport": {"create-statuses"}}, {"token": {}} - * * } * ) * @@ -372,13 +369,43 @@ public function create(Request $request): JsonResponse { 'destination' => ['required', 'numeric'], 'departure' => ['required', 'date'], 'arrival' => ['required', 'date'], - 'force' => ['nullable', 'boolean'] + 'force' => ['nullable', 'boolean'], + 'with' => ['nullable', 'array', 'max:10'], ]); + if (isset($validated['with'])) { + $withUsers = User::whereIn('id', $validated['with'])->get(); + $forbiddenUsers = collect(); + foreach ($withUsers as $user) { + if (!Auth::user()?->can('checkin', $user)) { + $forbiddenUsers->push($user); + } + } + if ($forbiddenUsers->isNotEmpty()) { + $forbiddenUserIds = $forbiddenUsers->pluck('id')->toArray(); + return response()->json( + data: [ + 'message' => 'You are not allowed to check in the following users: ' . implode(',', $forbiddenUserIds), + 'meta' => [ + 'invalidUsers' => $forbiddenUserIds + ] + ], + status: 403 + ); + } + } try { - $checkinResponse = TrainCheckinController::checkin((new CheckinRequestHydrator($validated))->hydrateFromApi()); + $dto = (new CheckinRequestHydrator($validated))->hydrateFromApi(); + $checkinResponse = TrainCheckinController::checkin($dto); + + // if isset, check in the other users with their default values + foreach ($withUsers ?? [] as $user) { + $dto->setUser($user); + $dto->setStatusVisibility($user->default_status_visibility); + $checkin = TrainCheckinController::checkin($dto); + $user->notify(new YouHaveBeenCheckedIn($checkin->status, auth()->user())); + } - //ToDo: Check if documented structure has changed return $this->sendResponse(new CheckinSuccessResource($checkinResponse), 201); } catch (CheckInCollisionException $exception) { return $this->sendError([ diff --git a/app/Http/Controllers/API/v1/TrustedUserController.php b/app/Http/Controllers/API/v1/TrustedUserController.php new file mode 100644 index 000000000..c20fcb0c3 --- /dev/null +++ b/app/Http/Controllers/API/v1/TrustedUserController.php @@ -0,0 +1,108 @@ +getUserOrSelf($userIdOrSelf); + $this->authorize('update', $user); + return TrustedUserResource::collection($user->trustedUsers()->orderBy('trusted_id')->cursorPaginate(10)); + } + + /** + * @OA\Post( + * path="/user/{user}/trusted", + * operationId="trustedUserStore", + * summary="Add a user to the trusted users for a user", + * description="Add a user to the trusted users for the current user or a specific user (admin only).", + * tags={"User"}, + * @OA\Parameter(name="user", in="path", required=true, description="ID of the user (or string 'self' for current user) who want's to trust.", @OA\Schema(type="string")), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"user_id"}, + * @OA\Property(property="userId", type="integer", example="1"), + * @OA\Property(property="expiresAt", type="string", format="date-time", example="2024-07-28T00:00:00Z") + * ) + * ), + * @OA\Response(response="201", description="User added to trusted users"), + * @OA\Response(response="400", description="Bad Request"), + * @OA\Response(response="401", description="Unauthorized"), + * @OA\Response(response="403", description="Forbidden"), + * @OA\Response(response="404", description="User not found"), + * @OA\Response(response="500", description="Internal Server Error"), + * ) + * @throws AuthorizationException + */ + public function store(Request $request, string|int $userIdOrSelf): Response { + $user = $this->getUserOrSelf($userIdOrSelf); + $validated = $request->validate([ + 'userId' => ['required', 'exists:users,id'], + 'expiresAt' => ['nullable', 'date', 'after:now'], + ]); + $trustedUser = User::find($validated['userId']); + $this->authorize('update', $user); + TrustedUser::updateOrCreate( + [ + 'user_id' => $user->id, + 'trusted_id' => $trustedUser->id, + ], + [ + 'expires_at' => $validated['expiresAt'] ?? null, + ] + ); + return response()->noContent(201); + } + + /** + * @OA\Delete( + * path="/user/{user}/trusted/{trustedId}", + * operationId="trustedUserDestroy", + * summary="Remove a user from the trusted users for a user", + * description="Remove a user from the trusted users for the current user or a specific user (admin only).", + * tags={"User"}, + * @OA\Parameter(name="user", in="path", required=true, description="ID of the user (or string 'self' for current user)", @OA\Schema(type="string")), + * @OA\Parameter(name="trusted", in="path", required=true, description="ID of the trusted user", @OA\Schema(type="integer")), + * @OA\Response(response="204", description="User removed from trusted users"), + * @OA\Response(response="401", description="Unauthorized"), + * @OA\Response(response="403", description="Forbidden"), + * @OA\Response(response="404", description="User not found"), + * @OA\Response(response="500", description="Internal Server Error"), + * ) + * @throws AuthorizationException + */ + public function destroy(string|int $userIdOrSelf, int $trusted): Response { + $user = $this->getUserOrSelf($userIdOrSelf); + $trusted = User::findOrFail($trusted); + $this->authorize('update', $user); + TrustedUser::where('user_id', $user->id)->where('trusted_id', $trusted->id)->delete(); + return response()->noContent(); + } +} diff --git a/app/Http/Controllers/Backend/User/FollowController.php b/app/Http/Controllers/Backend/User/FollowController.php index fe1e5aea4..42b4ebfcf 100644 --- a/app/Http/Controllers/Backend/User/FollowController.php +++ b/app/Http/Controllers/Backend/User/FollowController.php @@ -118,9 +118,14 @@ public static function createOrRequestFollow(User $user, User $userToFollow): Us 'user_id' => $user->id, 'follow_id' => $userToFollow->id ]); - $userToFollow->fresh(); + $userToFollow->refresh(); $userToFollow->notify(new UserFollowed($follow)); Cache::forget(CacheKey::getFriendsLeaderboardKey($user->id)); return $userToFollow; } + + public static function isFollowingEachOther(User $user, User $otherUser): bool { + return $user->userFollowers->contains('id', $otherUser->id) + && $user->userFollowings->contains('id', $otherUser->id); + } } diff --git a/app/Http/Resources/TrustedUserResource.php b/app/Http/Resources/TrustedUserResource.php new file mode 100644 index 000000000..3602787d6 --- /dev/null +++ b/app/Http/Resources/TrustedUserResource.php @@ -0,0 +1,22 @@ + new LightUserResource($this->trusted), + 'expiresAt' => $this->expires_at?->toIso8601String() + ]; + } +} diff --git a/app/Http/Resources/UserBaseResource.php b/app/Http/Resources/UserBaseResource.php index ed6bd1ec1..ce1490fee 100644 --- a/app/Http/Resources/UserBaseResource.php +++ b/app/Http/Resources/UserBaseResource.php @@ -29,7 +29,8 @@ public function toArray($request): array { [ 'home' => $this->home, 'language' => $this->language, - 'defaultStatusVisibility' => $this->default_status_visibility + 'defaultStatusVisibility' => $this->default_status_visibility, + 'friendCheckin' => $this->friend_checkin, ]), $this->mergeWhen(isset($this->UserResource), [ diff --git a/app/Models/TrustedUser.php b/app/Models/TrustedUser.php new file mode 100644 index 000000000..d7e1723f5 --- /dev/null +++ b/app/Models/TrustedUser.php @@ -0,0 +1,40 @@ + 'string', + 'user_id' => 'integer', + 'trusted_id' => 'integer', + 'expires_at' => 'datetime', + ]; + + public function trusted(): BelongsTo { + return $this->belongsTo(User::class, 'trusted_id', 'id'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index da1e002ac..6759f6d4c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,7 @@ use App\Enum\MapProvider; use App\Enum\StatusVisibility; +use App\Enum\User\FriendCheckinSetting; use App\Exceptions\RateLimitExceededException; use App\Http\Controllers\Backend\Social\MastodonProfileDetails; use App\Jobs\SendVerificationEmail; @@ -18,6 +19,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\DatabaseNotification; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\RateLimiter; use Laravel\Passport\HasApiTokens; @@ -25,36 +27,56 @@ use Spatie\Permission\Traits\HasRoles; /** - * @property int id - * @property string username - * @property string name - * @property string avatar - * @property string email - * @property Carbon email_verified_at - * @property string password - * @property int home_id - * @property Carbon privacy_ack_at - * @property integer default_status_visibility - * @property boolean private_profile - * @property boolean prevent_index - * @property boolean likes_enabled - * @property boolean points_enabled - * @property MapProvider mapprovider - * @property int privacy_hide_days - * @property string language - * @property Carbon last_login - * @property Status[] $statuses - * @property SocialLoginProfile socialProfile - * @property int points - * @property boolean userInvisibleToMe - * @property string mastodonUrl - * @property int train_distance - * @property int train_duration - * @property boolean following - * @property boolean followPending - * @property boolean muted - * @property boolean isAuthUserBlocked - * @property boolean isBlockedByAuthUser + * // properties + * @property int id + * @property string username + * @property string name + * @property string avatar + * @property string email + * @property Carbon email_verified_at + * @property string password + * @property int home_id + * @property Carbon privacy_ack_at + * @property StatusVisibility default_status_visibility + * @property boolean private_profile + * @property boolean prevent_index + * @property boolean likes_enabled + * @property boolean points_enabled + * @property MapProvider mapprovider + * @property string timezone + * @property FriendCheckinSetting friend_checkin + * @property int privacy_hide_days + * @property string language + * @property Carbon last_login + * @property int points + * @property boolean userInvisibleToMe + * @property string mastodonUrl + * @property int train_distance + * @property int train_duration + * @property boolean following + * @property boolean followPending + * @property boolean muted + * @property boolean isAuthUserBlocked + * @property boolean isBlockedByAuthUser + * + * // relationships + * @property Collection trainCheckins + * @property SocialLoginProfile socialProfile + * @property Station home + * @property Collection likes + * @property Collection follows + * @property Collection blockedUsers + * @property Collection blockedByUsers + * @property Collection mutedUsers + * @property Collection followRequests + * @property Collection userFollowers + * @property Collection userFollowings + * @property Collection sessions + * @property Collection icsTokens + * @property Collection webhooks + * @property Collection notifications + * @property Collection statuses + * @property Collection trustedUsers * * * @todo rename home_id to home_station_id @@ -69,7 +91,7 @@ class User extends Authenticatable implements MustVerifyEmail protected $fillable = [ 'username', 'name', 'avatar', 'email', 'email_verified_at', 'password', 'home_id', 'privacy_ack_at', 'default_status_visibility', 'likes_enabled', 'points_enabled', 'private_profile', 'prevent_index', - 'privacy_hide_days', 'language', 'last_login', 'mapprovider', 'timezone', + 'privacy_hide_days', 'language', 'last_login', 'mapprovider', 'timezone', 'friend_checkin', ]; protected $hidden = [ 'password', 'remember_token', 'email', 'email_verified_at', 'privacy_ack_at', @@ -92,16 +114,14 @@ class User extends Authenticatable implements MustVerifyEmail 'privacy_hide_days' => 'integer', 'last_login' => 'datetime', 'mapprovider' => MapProvider::class, + 'timezone' => 'string', + 'friend_checkin' => FriendCheckinSetting::class, ]; public function getTrainDistanceAttribute(): float { return Checkin::where('user_id', $this->id)->sum('distance'); } - public function statuses(): HasMany { - return $this->hasMany(Status::class); - } - public function trainCheckins(): HasMany { return $this->hasMany(Checkin::class, 'user_id', 'id'); } @@ -150,14 +170,14 @@ public function followRequests(): HasMany { } /** - * @deprecated + * @deprecated use ->userFollowers instead to get the users directly */ public function followers(): HasMany { return $this->hasMany(Follow::class, 'follow_id', 'id'); } /** - * @deprecated + * @deprecated use ->userFollowing instead to get the users directly */ public function followings(): HasMany { return $this->hasMany(Follow::class, 'user_id', 'id'); @@ -182,18 +202,20 @@ public function getPointsAttribute(): int { ->sum('points'); } - /** - * @untested - * @todo test - */ + public function statuses(): HasMany { + return $this->hasMany(Status::class); + } + + public function trustedUsers(): HasMany { + return $this->hasMany(TrustedUser::class, 'user_id', 'id') + ->with(['trusted']) + ->whereNull('expires_at')->orWhere('expires_at', '>', now()); + } + public function userFollowings(): BelongsToMany { return $this->belongsToMany(__CLASS__, 'follows', 'user_id', 'follow_id'); } - /** - * @untested - * @todo test - */ public function userFollowers(): BelongsToMany { return $this->belongsToMany(__CLASS__, 'follows', 'follow_id', 'user_id'); } diff --git a/app/Notifications/YouHaveBeenCheckedIn.php b/app/Notifications/YouHaveBeenCheckedIn.php new file mode 100644 index 000000000..86e9d0fbf --- /dev/null +++ b/app/Notifications/YouHaveBeenCheckedIn.php @@ -0,0 +1,56 @@ +status = $status; + $this->userCheckedIn = $userCheckedIn; + } + + public function via(): array { + return ['database']; + } + + public function toArray(): array { + return [ + 'status' => $this->status->only(['id']), + 'checkin' => [ + 'line' => $this->status->checkin->trip->linename, + 'origin' => $this->status->checkin->originStopover->station->name, + 'destination' => $this->status->checkin->destinationStopover->station->name, + ], + 'user' => $this->userCheckedIn->only(['id', 'username', 'name']), + ]; + } + + public static function getLead(array $data): string { + return __('notifications.youHaveBeenCheckedIn.lead', [ + 'username' => $data['user']['username'], + ]); + } + + public static function getNotice(array $data): ?string { + return __('notifications.userJoinedConnection.notice', [ + 'line' => $data['checkin']['line'], + 'origin' => $data['checkin']['origin'], + 'destination' => $data['checkin']['destination'], + ] + ); + } + + public static function getLink(array $data): ?string { + return route('status', ['id' => $data['status']['id']]); + } +} diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php index de790f832..c52f3c7dd 100644 --- a/app/Policies/UserPolicy.php +++ b/app/Policies/UserPolicy.php @@ -2,7 +2,9 @@ namespace App\Policies; +use App\Enum\User\FriendCheckinSetting; use App\Http\Controllers\Backend\User\BlockController; +use App\Http\Controllers\Backend\User\FollowController; use App\Models\User; use Illuminate\Auth\Access\HandlesAuthorization; use Illuminate\Auth\Access\Response; @@ -64,7 +66,7 @@ public function view(?User $user, User $model): Response { * @return bool */ public function update(User $user, User $model): bool { - return $user->id === $model->id; + return $user->id === $model->id || $user->hasRole('admin'); } /** @@ -78,4 +80,25 @@ public function update(User $user, User $model): bool { public function delete(User $user, User $model): bool { return $user->id === $model->id; } + + /** + * Check if user can check in another user + * + * @param User $user + * @param User $userToCheckin + * + * @return bool + */ + public function checkin(User $user, User $userToCheckin): bool { + if ($userToCheckin->friend_checkin === FriendCheckinSetting::FORBIDDEN) { + return false; + } + if ($userToCheckin->friend_checkin === FriendCheckinSetting::FRIENDS) { + return FollowController::isFollowingEachOther($user, $userToCheckin); + } + if ($userToCheckin->friend_checkin === FriendCheckinSetting::LIST) { + return $userToCheckin->trustedUsers->contains('trusted_id', $user->id); + } + return $user->is($userToCheckin); + } } diff --git a/app/Virtual/Models/CheckinRequestBody.php b/app/Virtual/Models/CheckinRequestBody.php index 4044d6701..4bad40506 100644 --- a/app/Virtual/Models/CheckinRequestBody.php +++ b/app/Virtual/Models/CheckinRequestBody.php @@ -2,161 +2,29 @@ namespace App\Virtual\Models; -use Carbon\Carbon; - /** * @OA\Schema( * title="CheckinRequestBody", * description="Fields for creating a train checkin", - * @OA\Xml( - * name="CheckinRequestBody" - * ) + * @OA\Property(property="body", type="string", maxLength=280, nullable=true, example="Meine erste Fahrt nach Knuffingen!"), + * @OA\Property(property="business", ref="#/components/schemas/Business",), + * @OA\Property(property="visibility", ref="#/components/schemas/StatusVisibility",), + * @OA\Property(property="eventId", type="integer", nullable=true, example="1", description="Id of an event the status should be connected to"), + * @OA\Property(property="toot", type="boolean", nullable=true, example="false", description="Should this status be posted to mastodon?"), + * @OA\Property(property="chainPost", type="boolean", nullable=true, example="false", description="Should this status be posted to mastodon as a chained post?"), + * @OA\Property(property="ibnr", type="boolean", nullable=true, example="true", description="If true, the `start` and `destination` properties can be supplied as an ibnr. Otherwise they should be given as the Träwelling-ID. Default behavior is `false`."), + * @OA\Property(property="tripId", type="string", nullable=true, example="b37ff515-22e1-463c-94de-3ad7964b5cb8", description="The tripId for the to be checked in train"), + * @OA\Property(property="lineName", type="string", nullable=true, example="S 4", description="The line name for the to be checked in train"), + * @OA\Property(property="start", type="integer", example="8000191", description="The Station-ID of the starting point (see `ibnr`)"), + * @OA\Property(property="destination", type="integer", example="8000192", description="The Station-ID of the destination point (see `ibnr`)"), + * @OA\Property(property="departure", type="string", format="date-time", example="2022-12-19T20:41:00+01:00", description="Timestamp of the departure"), + * @OA\Property(property="arrival", type="string", format="date-time", example="2022-12-19T20:42:00+01:00", description="Timestamp of the arrival"), + * @OA\Property(property="force", type="boolean", nullable=true, example="false", description="If true, the checkin will be created, even if a colliding checkin exists. No points will be awarded."), + * @OA\Property(property="with", type="array", @OA\Items(type="integer", example="1"), example="[1, 2]", nullable=true, description="If set, the checkin will be created for all given users as well. The user creating the checkin must be allowed to checkin for the other users. Max. 10 users."), * ) */ class CheckinRequestBody { - /** - * @OA\Property( - * title="body", - * description="Text that should be added to the post", - * type="string", - * maxLength=280, - * nullable=true, - * example="Meine erste Fahrt nach Knuffingen!" - * ) - * - * @var string - */ - private $body; - - /** - * @OA\Property ( - * ref="#/components/schemas/Business", - * ) - * - * @var integer - */ - private $business; - - /** - * @OA\Property ( - * ref="#/components/schemas/StatusVisibility" - * ) - * - * @var integer - */ - private $visibility; - - /** - * @OA\Property ( - * title="eventId", - * nullable=true, - * description="Id of an event the status should be connected to", - * type="integer" - * ) - */ - private $eventId; - - /** - * @OA\Property ( - * title="toot", - * nullable=true, - * description="Should this status be posted to mastodon?", - * type="boolean", - * example="false" - * ) - */ - private $toot; - - /** - * @OA\Property ( - * title="chainPost", - * nullable=true, - * description="Should this status be posted to mastodon as a chained post?", - * type="boolean", - * example="false" - * ) - */ - private $chainPost; - - /** - * @OA\Property ( - * title="ibnr", - * nullable=true, - * description="If true, the `start` and `destination` properties can be supplied as an ibnr. Otherwise they - * should be given as the Träwelling-ID. Default behavior is `false`.", type="boolean", example="true", - * ) - */ - private $ibnr; - - /** - * @OA\Property ( - * title="tripId", - * description="The HAFAS tripId for the to be checked in train", - * example="1|323306|1|80|17072022" - * ) - */ - private $tripId; - - /** - * @OA\Property ( - * title="lineName", - * description="The line name for the to be checked in train", - * example="S 4" - * ) - */ - private $lineName; - - /** - * @OA\Property ( - * title="start", - * description="The Station-ID of the starting point (see `ibnr`)", - * example="8000191", - * type="integer" - * ) - */ - private $start; - - /** - * @OA\Property ( - * title="destination", - * description="The Station-ID of the destination (see `ibnr`)", - * example="8079045", - * type="integer" - * ) - */ - private $destination; - - /** - * @OA\Property ( - * title="departure", - * description="Timestamp of the departure", - * example="2022-12-19T20:41:00+01:00", - * ) - * - * @var Carbon - */ - private $departure; - - /** - * @OA\Property ( - * title="arrival", - * description="Timestamp of the arrival", - * example="2022-12-19T20:42:00+01:00", - * ) - * - * @var Carbon - */ - private $arrival; - /** - * @OA\Property ( - * title="force", - * nullable=true, - * description="If true, the checkin will be created, even if a colliding checkin exists. No points will be - * awarded.", type="boolean", example="false", - * ) - */ - private $force; } diff --git a/app/Virtual/Models/Response/CheckinForbiddenWithUsersResponse.php b/app/Virtual/Models/Response/CheckinForbiddenWithUsersResponse.php new file mode 100644 index 000000000..c0e2cc296 --- /dev/null +++ b/app/Virtual/Models/Response/CheckinForbiddenWithUsersResponse.php @@ -0,0 +1,28 @@ +string('friend_checkin')->default('forbidden')->after('timezone'); + }); + } + + public function down(): void { + Schema::table('users', function(Blueprint $table) { + $table->dropColumn('friend_checkin'); + }); + } +}; diff --git a/database/migrations/2024_07_28_000001_add_trusted_users_table.php b/database/migrations/2024_07_28_000001_add_trusted_users_table.php new file mode 100644 index 000000000..7bec63e32 --- /dev/null +++ b/database/migrations/2024_07_28_000001_add_trusted_users_table.php @@ -0,0 +1,28 @@ +uuid('id')->primary(); + + $table->foreignId('user_id')->constrained('users')->onDelete('cascade'); + $table->foreignId('trusted_id')->constrained('users')->onDelete('cascade'); + $table->timestamp('expires_at')->nullable(); + + $table->timestamps(); + + $table->unique(['user_id', 'trusted_id']); + + $table->comment('This table is used to store trusted users for friend checkin.'); + }); + } + + public function down(): void { + Schema::dropIfExists('trusted_users'); + } +}; diff --git a/lang/de.json b/lang/de.json index 8c89c4820..8c835ac2a 100644 --- a/lang/de.json +++ b/lang/de.json @@ -146,6 +146,7 @@ "events.request-button": "Melden", "events.notice": "Deine Meldung wird nur veröffentlicht, wenn das Träwelling-Team sie freigegeben hat.", "events.request.success": "Dein Vorschlag ist bei uns angekommen. Vielen Dank!", + "events.show-all-for-event": "Alle Check-ins zum Event anzeigen", "event": "Veranstaltung", "events": "Veranstaltungen", "auth.required": "Du musst eingeloggt sein, um diese Funktion nutzen zu können.", @@ -763,5 +764,7 @@ "action.error": "Diese Aktion konnte leider nicht ausgeführt werden. Bitte versuche es später noch einmal.", "action.like": "Status liken", "action.dislike": "Status nicht mehr liken", - "action.set-home": "Heimathaltestelle setzen" + "action.set-home": "Heimathaltestelle setzen", + "notifications.youHaveBeenCheckedIn.lead": "Du wurdest von :username eingecheckt", + "notifications.youHaveBeenCheckedIn.notice": "Fahrt in :line von :origin nach :destination" } diff --git a/lang/en.json b/lang/en.json index 2ebd3aa7c..d000b00a3 100644 --- a/lang/en.json +++ b/lang/en.json @@ -128,6 +128,7 @@ "events.live": "Live Events", "events.past": "Past Events", "events.new": "Create Event", + "events.show-all-for-event": "Show all check-ins for this event", "export.begin": "Start", "export.btn": "Export data", "export.end": "End", @@ -228,6 +229,8 @@ "notifications.statusLiked.lead": "@:likerUsername liked your check-in.", "notifications.statusLiked.notice": "Journey in :line on :createdDate|Journey in line :line on :createdDate", "notifications.userFollowed.lead": "@:followerUsername follows you now.", + "notifications.youHaveBeenCheckedIn.lead": "You have been checked in by @:username", + "notifications.youHaveBeenCheckedIn.notice": "Journey in :line from :origin to :destination", "notifications.userRequestedFollow.lead": "@:followerRequestUsername wants to follow you.", "notifications.userRequestedFollow.notice": "To accept or decline the follow request, click here or go to your settings.", "notifications.userApprovedFollow.lead": "@:followerRequestUsername has approved your follow request.", diff --git a/resources/sass/app-dark.scss b/resources/sass/app-dark.scss index b33f607e4..af5247644 100644 --- a/resources/sass/app-dark.scss +++ b/resources/sass/app-dark.scss @@ -1,4 +1,5 @@ @import "variables"; +@import "mdb-ui-kit/src/scss/free/_variables"; :root.dark { --mdb-body-color: #{$dm-body}; @@ -6,12 +7,16 @@ --mdb-link-color: #{$dm-lightblue}; --mdb-btn-hover-color: #{$dm-lightblue}; - --mdb-btn-hover-color: #{$dm-lightblue}; --mdb-btn-focus-color: #{$dm-lightblue}; --mdb-btn-active-color: #{$dm-lightblue}; --mdb-nav-tabs-link-color: $dm-lightblue; + $box-shadow: 0 0.5rem 1rem rgba($gray-300, 0.15) !default; + $box-shadow-sm: 0 0.125rem 0.25rem rgba($gray-300, 0.075) !default; + $box-shadow-lg: 0 1rem 3rem rgba($gray-300, 0.175) !default; + $box-shadow-inset: inset 0 1px 2px rgba($gray-300, 0.075) !default; + body { background-color: $dm-base; color: $dm-body !important; @@ -46,6 +51,7 @@ background-color: lighten($dm-base, 2%); border-color: lighten($dm-base, 30%) !important; color: lighten($dm-body, 8%) !important; + &:focus { background-color: lighten($dm-base, 10%); } @@ -55,6 +61,7 @@ background-color: lighten($dm-base, 2%); color: $dm-body !important; border-color: lighten($dm-base, 30%) !important; + &:focus { background-color: lighten($dm-base, 10%); } @@ -116,6 +123,7 @@ background: lighten($dm-base, 30%) !important; border-color: $dm-trwlRot-shape; } + .text-trwl { color: $dm-trwlRot !important; } @@ -130,16 +138,17 @@ .progress { .progress-bar { background: $dm-trwlRot-shape; + &.progress-pride { background: linear-gradient( - 66deg, - hsl(0, 75%, 60%) 0 14.28%, - hsl(39, 75%, 47%) 0 28.57%, - hsl(60, 65%, 50%) 0 42.85%, - hsl(120, 75%, 40%) 0 57.14%, - hsl(181, 75%, 40%) 0 71.42%, - hsl(240, 75%, 60%) 0 85.71%, - hsl(275, 75%, 35%) 0 + 66deg, + hsl(0, 75%, 60%) 0 14.28%, + hsl(39, 75%, 47%) 0 28.57%, + hsl(60, 65%, 50%) 0 42.85%, + hsl(120, 75%, 40%) 0 57.14%, + hsl(181, 75%, 40%) 0 71.42%, + hsl(240, 75%, 60%) 0 85.71%, + hsl(275, 75%, 35%) 0 ); } } @@ -281,6 +290,7 @@ .list-group-item { background-color: lighten($dm-base, 15%); color: $dm-body !important; + &:hover { background-color: lighten($dm-base, 10%); } @@ -338,6 +348,7 @@ .alert-info { background-color: hsl(194, 80%, 20%); color: rgb(238, 238, 238); + i { color: hsl(195, 63%, 70%); } @@ -362,8 +373,8 @@ background: linear-gradient( #{rgba(darken($dm-trwlRot, 60%), 0.4)}, #{rgba(darken($dm-trwlRot, 60%), 0.4)} - ), - url("/images/covers/profile-background.png"); + ), + url("/images/covers/profile-background.png"); background-position: center; } @@ -373,8 +384,7 @@ --mdb-pagination-hover-border-color: #{$dm-base-20} !important; --mdb-pagination-focus-color: #{$dm-body} !important; --mdb-pagination-focus-bg: #{$dm-base-30} !important; - --mdb-pagination-focus-box-shadow: 0 0 0 0.25rem - rgba(59, 113, 202, 0.25); + --mdb-pagination-focus-box-shadow: 0 0 0 0.25rem rgba(59, 113, 202, 0.25); --mdb-pagination-active-color: #{$dm-body} !important; --mdb-pagination-active-bg: #{$dm-base-30} !important; --mdb-pagination-active-border-color: #{$dm-base-30} !important; @@ -382,9 +392,11 @@ --mdb-pagination-disabled-bg: #{$dm-base} !important; --mdb-pagination-disabled-border-color: #{$dm-base} !important; } + a.page-link { color: #{$dm-body} !important; } + .page-item { &.disabled { .page-link { @@ -425,9 +437,9 @@ .leaflet-layer { &:nth-child(1) .leaflet-tile { - filter: invert(1) grayscale(100%) sepia(100%) hue-rotate(180deg) - saturate(0.7); + filter: invert(1) grayscale(100%) sepia(100%) hue-rotate(180deg) saturate(0.7); } + &:nth-child(2) .leaflet-tile { filter: invert(1) hue-rotate(180deg) grayscale(70%) opacity(0.65); } @@ -441,4 +453,8 @@ .leaflet-interactive { stroke: $dm-trwlRot-shape; } + + .shadow-sm { + box-shadow: $box-shadow-sm !important; + } } diff --git a/resources/views/settings/profile.blade.php b/resources/views/settings/profile.blade.php index c9bdd5868..ce7a8b78f 100644 --- a/resources/views/settings/profile.blade.php +++ b/resources/views/settings/profile.blade.php @@ -23,11 +23,11 @@ /> - {{__('settings.upload-image')}} - +
import 'leaflet'; +import {trans} from "laravel-vue-i18n"; +import {DtmRange} from "../helpers/DateRange"; import('Leaflet-MovingMaker/MovingMarker'); @@ -66,6 +68,7 @@ export default { } }, methods: { + trans, renderMap() { this.map = L.map(this.$refs.map, { center: [50.3, 10.47], @@ -148,11 +151,13 @@ export default { icon: eventIcon }).addTo(this.map); + const range = DtmRange.fromISO(event.begin, event.end); + marker.bindPopup(` ${event.name}
${event.host}
- ${event.begin} - ${event.end}
- Alle Reisen zum Event anzeigen` + ${range.toLocaleDateString()}
+ ${trans('events.show-all-for-event')}` ); }, getIconForStatus(response) { diff --git a/resources/vue/components/ActiveStatusCard.vue b/resources/vue/components/ActiveStatusCard.vue index fd8159ebd..85b1199f2 100644 --- a/resources/vue/components/ActiveStatusCard.vue +++ b/resources/vue/components/ActiveStatusCard.vue @@ -64,7 +64,7 @@ export default defineComponent({ }, mounted() { this.fetchState(); - setTimeout(this.getNextStation,500); + setTimeout(this.getNextStation, 500); this.fetchInterval = setInterval(this.fetchState, 30000); this.nextStationInterval = setInterval(this.getNextStation, 10000); }, @@ -81,13 +81,14 @@ export default defineComponent({