Merge branch 'main' of github.com:solidtime-io/solidtime into feature/dashboard_empty_states
# Conflicts: # resources/js/Components/Dashboard/DashboardCard.vue # resources/js/Components/Dashboard/RecentlyTrackedTasksCard.vue
@@ -1,4 +1,5 @@
|
||||
APP_NAME=solidtime
|
||||
VITE_APP_NAME=solidtime
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
APP_FORCE_HTTPS=true
|
||||
|
||||
33
app/Exceptions/Api/EntityStillInUseApiException.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class EntityStillInUseApiException extends ApiException
|
||||
{
|
||||
private string $modelToDelete;
|
||||
|
||||
private string $modelInUse;
|
||||
|
||||
public function __construct(string $modelToDelete, string $modelInUse)
|
||||
{
|
||||
parent::__construct('', 0, null);
|
||||
$this->modelToDelete = $modelToDelete;
|
||||
$this->modelInUse = $modelInUse;
|
||||
}
|
||||
|
||||
public const string KEY = 'entity_still_in_use';
|
||||
|
||||
/**
|
||||
* Get the translated message for the exception.
|
||||
*/
|
||||
#[\Override]
|
||||
public function getTranslatedMessage(): string
|
||||
{
|
||||
return __('exceptions.api.'.$this->getKey(), [
|
||||
'modelToDelete' => __('validation.entities.'.$this->modelToDelete),
|
||||
'modelInUse' => __('validation.entities.'.$this->modelInUse),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
use App\Http\Requests\V1\Tag\TagStoreRequest;
|
||||
use App\Http\Requests\V1\Tag\TagUpdateRequest;
|
||||
use App\Http\Resources\V1\Client\ClientCollection;
|
||||
@@ -83,7 +84,7 @@ class ClientController extends Controller
|
||||
/**
|
||||
* Delete client
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws AuthorizationException|EntityStillInUseApiException
|
||||
*
|
||||
* @operationId deleteClient
|
||||
*/
|
||||
@@ -91,6 +92,10 @@ class ClientController extends Controller
|
||||
{
|
||||
$this->checkPermission($organization, 'clients:delete', $client);
|
||||
|
||||
if ($client->projects()->exists()) {
|
||||
throw new EntityStillInUseApiException('client', 'project');
|
||||
}
|
||||
|
||||
$client->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
|
||||
@@ -9,12 +9,21 @@ use App\Http\Requests\V1\Invitation\InvitationStoreRequest;
|
||||
use App\Http\Resources\V1\Invitation\InvitationCollection;
|
||||
use App\Http\Resources\V1\Invitation\InvitationResource;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
|
||||
|
||||
class InvitationController extends Controller
|
||||
{
|
||||
protected function checkPermission(Organization $organization, string $permission, ?OrganizationInvitation $organizationInvitation = null): void
|
||||
{
|
||||
parent::checkPermission($organization, $permission);
|
||||
if ($organizationInvitation !== null && $organizationInvitation->organization_id !== $organization->id) {
|
||||
throw new AuthorizationException('Invitation does not belong to organization');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all invitations of an organization
|
||||
*
|
||||
@@ -54,4 +63,20 @@ class InvitationController extends Controller
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a pending invitation
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId removeInvitation
|
||||
*/
|
||||
public function destroy(Organization $organization, OrganizationInvitation $invitation): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'invitations:remove', $invitation);
|
||||
|
||||
$invitation->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
use App\Exceptions\Api\UserNotPlaceholderApiException;
|
||||
use App\Http\Requests\V1\Member\MemberIndexRequest;
|
||||
use App\Http\Requests\V1\Member\MemberUpdateRequest;
|
||||
@@ -12,6 +13,8 @@ use App\Http\Resources\V1\Member\MemberPivotResource;
|
||||
use App\Http\Resources\V1\Member\MemberResource;
|
||||
use App\Models\Membership;
|
||||
use App\Models\Organization;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\TimeEntry;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -68,7 +71,7 @@ class MemberController extends Controller
|
||||
/**
|
||||
* Remove a member of the organization.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws AuthorizationException|EntityStillInUseApiException
|
||||
*
|
||||
* @operationId removeMember
|
||||
*/
|
||||
@@ -76,6 +79,13 @@ class MemberController extends Controller
|
||||
{
|
||||
$this->checkPermission($organization, 'members:delete', $membership);
|
||||
|
||||
if (TimeEntry::query()->where('user_id', $membership->user_id)->whereBelongsTo($organization, 'organization')->exists()) {
|
||||
throw new EntityStillInUseApiException('member', 'time_entry');
|
||||
}
|
||||
if (ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $membership->user_id)->exists()) {
|
||||
throw new EntityStillInUseApiException('member', 'project_member');
|
||||
}
|
||||
|
||||
$membership->delete();
|
||||
|
||||
return response()
|
||||
|
||||
@@ -4,17 +4,20 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
use App\Http\Requests\V1\Project\ProjectStoreRequest;
|
||||
use App\Http\Requests\V1\Project\ProjectUpdateRequest;
|
||||
use App\Http\Resources\V1\Project\ProjectCollection;
|
||||
use App\Http\Resources\V1\Project\ProjectResource;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
@@ -113,7 +116,7 @@ class ProjectController extends Controller
|
||||
/**
|
||||
* Delete project
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws AuthorizationException|EntityStillInUseApiException
|
||||
*
|
||||
* @operationId deleteProject
|
||||
*/
|
||||
@@ -121,7 +124,20 @@ class ProjectController extends Controller
|
||||
{
|
||||
$this->checkPermission($organization, 'projects:delete', $project);
|
||||
|
||||
$project->delete();
|
||||
if ($project->tasks()->exists()) {
|
||||
throw new EntityStillInUseApiException('project', 'task');
|
||||
}
|
||||
if ($project->timeEntries()->exists()) {
|
||||
throw new EntityStillInUseApiException('project', 'time_entry');
|
||||
}
|
||||
|
||||
DB::transaction(function () use (&$project) {
|
||||
$project->members()->each(function (ProjectMember $member) {
|
||||
$member->delete();
|
||||
});
|
||||
|
||||
$project->delete();
|
||||
});
|
||||
|
||||
return response()
|
||||
->json(null, 204);
|
||||
|
||||
@@ -4,12 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
use App\Http\Requests\V1\Tag\TagStoreRequest;
|
||||
use App\Http\Requests\V1\Tag\TagUpdateRequest;
|
||||
use App\Http\Resources\V1\Tag\TagCollection;
|
||||
use App\Http\Resources\V1\Tag\TagResource;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Tag;
|
||||
use App\Models\TimeEntry;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
@@ -83,7 +85,7 @@ class TagController extends Controller
|
||||
/**
|
||||
* Delete tag
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws AuthorizationException|EntityStillInUseApiException
|
||||
*
|
||||
* @operationId deleteTag
|
||||
*/
|
||||
@@ -91,6 +93,10 @@ class TagController extends Controller
|
||||
{
|
||||
$this->checkPermission($organization, 'tags:delete', $tag);
|
||||
|
||||
if (TimeEntry::query()->hasTag($tag)->whereBelongsTo($organization, 'organization')->exists()) {
|
||||
throw new EntityStillInUseApiException('tag', 'time_entry');
|
||||
}
|
||||
|
||||
$tag->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
use App\Http\Requests\V1\Task\TaskIndexRequest;
|
||||
use App\Http\Requests\V1\Task\TaskStoreRequest;
|
||||
use App\Http\Requests\V1\Task\TaskUpdateRequest;
|
||||
@@ -104,7 +105,7 @@ class TaskController extends Controller
|
||||
/**
|
||||
* Delete task
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws AuthorizationException|EntityStillInUseApiException
|
||||
*
|
||||
* @operationId deleteTask
|
||||
*/
|
||||
@@ -112,6 +113,10 @@ class TaskController extends Controller
|
||||
{
|
||||
$this->checkPermission($organization, 'tasks:delete', $task);
|
||||
|
||||
if ($task->timeEntries()->exists()) {
|
||||
throw new EntityStillInUseApiException('task', 'time_entry');
|
||||
}
|
||||
|
||||
$task->delete();
|
||||
|
||||
return response()
|
||||
|
||||
@@ -40,6 +40,7 @@ class Kernel extends HttpKernel
|
||||
\App\Http\Middleware\VerifyCsrfToken::class,
|
||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||
\App\Http\Middleware\HandleInertiaRequests::class,
|
||||
\App\Http\Middleware\ShareInertiaData::class,
|
||||
\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
|
||||
\Laravel\Passport\Http\Middleware\CreateFreshApiToken::class,
|
||||
],
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
use Nwidart\Modules\Facades\Module;
|
||||
|
||||
class HandleInertiaRequests extends Middleware
|
||||
{
|
||||
@@ -38,7 +39,7 @@ class HandleInertiaRequests extends Middleware
|
||||
public function share(Request $request): array
|
||||
{
|
||||
return array_merge(parent::share($request), [
|
||||
//
|
||||
'has_billing_extension' => Module::has('Billing'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
103
app/Http/Middleware/ShareInertiaData.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Illuminate\Support\MessageBag;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Fortify\Features;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ShareInertiaData
|
||||
{
|
||||
/**
|
||||
* Handle the incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
Inertia::share(array_filter([
|
||||
'jetstream' => function () use ($request) {
|
||||
/** @var User|null $user */
|
||||
$user = $request->user();
|
||||
|
||||
return [
|
||||
'canCreateTeams' => $user !== null &&
|
||||
Jetstream::userHasTeamFeatures($user) &&
|
||||
Gate::forUser($user)->check('create', Jetstream::newTeamModel()),
|
||||
'canManageTwoFactorAuthentication' => Features::canManageTwoFactorAuthentication(),
|
||||
'canUpdatePassword' => Features::enabled(Features::updatePasswords()),
|
||||
'canUpdateProfileInformation' => Features::canUpdateProfileInformation(),
|
||||
'hasEmailVerification' => Features::enabled(Features::emailVerification()),
|
||||
'flash' => $request->session()->get('flash', []),
|
||||
'hasAccountDeletionFeatures' => Jetstream::hasAccountDeletionFeatures(),
|
||||
'hasApiFeatures' => Jetstream::hasApiFeatures(),
|
||||
'hasTeamFeatures' => Jetstream::hasTeamFeatures(),
|
||||
'hasTermsAndPrivacyPolicyFeature' => Jetstream::hasTermsAndPrivacyPolicyFeature(),
|
||||
'managesProfilePhotos' => Jetstream::managesProfilePhotos(),
|
||||
];
|
||||
},
|
||||
'auth' => [
|
||||
'user' => function () use ($request): array {
|
||||
/** @var User|null $user */
|
||||
$user = $request->user();
|
||||
|
||||
if ($user === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_merge([
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'email_verified_at' => $user->email_verified_at,
|
||||
'current_team_id' => $user->current_team_id,
|
||||
'profile_photo_path' => $user->profile_photo_path,
|
||||
'timezone' => $user->timezone,
|
||||
'week_start' => $user->week_start,
|
||||
'profile_photo_url' => $user->profile_photo_url,
|
||||
'two_factor_enabled' => Features::enabled(Features::twoFactorAuthentication())
|
||||
&& ! is_null($user->two_factor_secret),
|
||||
'current_team' => $user->currentTeam !== null ? [
|
||||
'id' => $user->currentTeam->id,
|
||||
'user_id' => $user->currentTeam->user_id,
|
||||
'name' => $user->currentTeam->name,
|
||||
'personal_team' => $user->currentTeam->personal_team,
|
||||
'currency' => $user->currentTeam->currency,
|
||||
] : null,
|
||||
], array_filter([
|
||||
'all_teams' => $user->organizations->map(function (Organization $organization): array {
|
||||
return [
|
||||
'id' => $organization->id,
|
||||
'name' => $organization->name,
|
||||
'personal_team' => $organization->personal_team,
|
||||
'currency' => $organization->currency,
|
||||
'membership' => [
|
||||
'role' => $organization->membership->role,
|
||||
],
|
||||
];
|
||||
})->all(),
|
||||
]));
|
||||
},
|
||||
],
|
||||
'errorBags' => function () {
|
||||
/** @var array<string, MessageBag>|null $bags */
|
||||
$bags = Session::get('errors')?->getBags();
|
||||
$bagsCollection = collect($bags ?: []);
|
||||
|
||||
return $bagsCollection->mapWithKeys(function (MessageBag $bag, string $key) {
|
||||
return [$key => $bag->messages()];
|
||||
})->all();
|
||||
},
|
||||
]));
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Laravel\Jetstream\Events\TeamCreated;
|
||||
use Laravel\Jetstream\Events\TeamDeleted;
|
||||
use Laravel\Jetstream\Events\TeamUpdated;
|
||||
@@ -24,8 +25,11 @@ use Laravel\Jetstream\Team as JetstreamTeam;
|
||||
* @property int|null $billable_rate
|
||||
* @property string $user_id
|
||||
* @property User $owner
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property Collection<User> $users
|
||||
* @property Collection<string, User> $realUsers
|
||||
* @property Membership $membership
|
||||
*
|
||||
* @method HasMany<OrganizationInvitation> teamInvitations()
|
||||
* @method static OrganizationFactory factory()
|
||||
|
||||
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\OrganizationInvitationFactory;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
use Laravel\Jetstream\TeamInvitation as JetstreamTeamInvitation;
|
||||
@@ -15,9 +17,12 @@ use Laravel\Jetstream\TeamInvitation as JetstreamTeamInvitation;
|
||||
* @property string $role
|
||||
* @property string $organization_id
|
||||
* @property-read Organization $organization
|
||||
*
|
||||
* @method static OrganizationInvitationFactory factory()
|
||||
*/
|
||||
class OrganizationInvitation extends JetstreamTeamInvitation
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUuids;
|
||||
|
||||
/**
|
||||
|
||||
@@ -74,6 +74,14 @@ class Project extends Model
|
||||
return $this->hasMany(Task::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<TimeEntry>
|
||||
*/
|
||||
public function timeEntries(): HasMany
|
||||
{
|
||||
return $this->hasMany(TimeEntry::class, 'project_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Project> $builder
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Models;
|
||||
use App\Service\BillableRateService;
|
||||
use Carbon\CarbonInterval;
|
||||
use Database\Factories\TimeEntryFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -31,6 +32,7 @@ use Korridor\LaravelComputedAttributes\ComputedAttributes;
|
||||
* @property string|null $task_id
|
||||
* @property-read Task|null $task
|
||||
*
|
||||
* @method Builder<TimeEntry> hasTag(Tag $tag)
|
||||
* @method static TimeEntryFactory factory()
|
||||
*/
|
||||
class TimeEntry extends Model
|
||||
@@ -73,6 +75,14 @@ class TimeEntry extends Model
|
||||
return $this->end === null ? null : $this->start->diffAsCarbonInterval($this->end);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<TimeEntry> $builder
|
||||
*/
|
||||
public function scopeHasTag(Builder $builder, Tag $tag): void
|
||||
{
|
||||
$builder->whereJsonContains('tags', $tag->getKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, TimeEntry>
|
||||
*/
|
||||
|
||||
@@ -10,6 +10,7 @@ use Filament\Models\Contracts\FilamentUser;
|
||||
use Filament\Panel;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -17,6 +18,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Laravel\Jetstream\HasProfilePhoto;
|
||||
use Laravel\Jetstream\HasTeams;
|
||||
@@ -28,14 +31,19 @@ use Laravel\Passport\HasApiTokens;
|
||||
* @property string $email
|
||||
* @property string|null $email_verified_at
|
||||
* @property string|null $password
|
||||
* @property string|null $two_factor_secret
|
||||
* @property string $timezone
|
||||
* @property bool $is_placeholder
|
||||
* @property Weekday $week_start
|
||||
* @property string|null $profile_photo_path
|
||||
* @property-read Organization $currentTeam
|
||||
* @property-read string $profile_photo_url
|
||||
* @property Collection<Organization> $organizations
|
||||
* @property Collection<TimeEntry> $timeEntries
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property string $current_team_id
|
||||
* @property Collection<int, Organization> $organizations
|
||||
* @property Collection<int, TimeEntry> $timeEntries
|
||||
* @property Membership $membership
|
||||
*
|
||||
* @method HasMany<Organization> ownedTeams()
|
||||
* @method static UserFactory factory()
|
||||
@@ -99,6 +107,20 @@ class User extends Authenticatable implements FilamentUser, MustVerifyEmail
|
||||
'week_start' => Weekday::Monday,
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the URL to the user's profile photo.
|
||||
*
|
||||
* @return Attribute<string, never>
|
||||
*/
|
||||
protected function profilePhotoUrl(): Attribute
|
||||
{
|
||||
return Attribute::get(function (): string {
|
||||
return $this->profile_photo_path
|
||||
? Storage::disk($this->profilePhotoDisk())->url($this->profile_photo_path)
|
||||
: $this->defaultProfilePhotoUrl();
|
||||
});
|
||||
}
|
||||
|
||||
public function canAccessPanel(Panel $panel): bool
|
||||
{
|
||||
return in_array($this->email, config('auth.super_admins', []), true) && $this->hasVerifiedEmail();
|
||||
@@ -127,6 +149,14 @@ class User extends Authenticatable implements FilamentUser, MustVerifyEmail
|
||||
return $this->hasMany(TimeEntry::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ProjectMember>
|
||||
*/
|
||||
public function projectMembers(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProjectMember::class, 'user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<User> $builder
|
||||
*/
|
||||
|
||||
@@ -86,5 +86,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
});
|
||||
|
||||
Route::model('member', Membership::class);
|
||||
Route::model('invitation', OrganizationInvitation::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,9 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'organizations:view',
|
||||
'organizations:update',
|
||||
'import',
|
||||
'invitations:view',
|
||||
'invitations:create',
|
||||
'invitations:remove',
|
||||
'members:view',
|
||||
'members:invite-placeholder',
|
||||
'members:change-role',
|
||||
|
||||
@@ -40,6 +40,35 @@ class DashboardService
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{start: string, end: string, dates: array<string, array<string>>}
|
||||
*/
|
||||
private function lastDaysSplitInWindows(int $days, CarbonTimeZone $timeZone, int $windows): array
|
||||
{
|
||||
$result = [];
|
||||
$windowSize = 24 / $windows;
|
||||
$date = Carbon::now($timeZone)->startOfDay();
|
||||
$end = $date->copy()->endOfDay()->utc()->toDateTimeString();
|
||||
$start = $end;
|
||||
for ($i = 0; $i < $days; $i++) {
|
||||
$tempDate = $date->copy();
|
||||
$start = $tempDate->utc()->toDateTimeString();
|
||||
$tempWindows = [];
|
||||
for ($j = 0; $j < $windows; $j++) {
|
||||
$tempWindow = $tempDate->addHours($windowSize)->utc()->toDateTimeString();
|
||||
$tempWindows[] = $tempWindow;
|
||||
}
|
||||
$result[$date->format('Y-m-d')] = $tempWindows;
|
||||
$date->subDay();
|
||||
}
|
||||
|
||||
return [
|
||||
'start' => $start,
|
||||
'end' => $end,
|
||||
'dates' => $result,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, string>
|
||||
*/
|
||||
@@ -385,114 +414,45 @@ class DashboardService
|
||||
*/
|
||||
public function lastSevenDays(User $user, Organization $organization): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'date' => '2024-02-26',
|
||||
'duration' => 3600, // in seconds
|
||||
// if that is too difficult we can just skip that for now
|
||||
'history' => [
|
||||
// duration in s of the 3h windows for the day starting at 00:00
|
||||
300,
|
||||
0,
|
||||
500,
|
||||
0,
|
||||
100,
|
||||
200,
|
||||
100,
|
||||
300,
|
||||
],
|
||||
],
|
||||
[
|
||||
'date' => '2024-02-25',
|
||||
'duration' => 7200, // in seconds
|
||||
'history' => [
|
||||
// duration in s of the 3h windows for the day starting at 00:00
|
||||
300,
|
||||
0,
|
||||
500,
|
||||
0,
|
||||
100,
|
||||
200,
|
||||
100,
|
||||
300,
|
||||
],
|
||||
],
|
||||
[
|
||||
'date' => '2024-02-24',
|
||||
'duration' => 10800, // in seconds
|
||||
'history' => [
|
||||
// duration in s of the 3h windows for the day starting at 00:00
|
||||
300,
|
||||
0,
|
||||
500,
|
||||
0,
|
||||
100,
|
||||
200,
|
||||
100,
|
||||
300,
|
||||
],
|
||||
],
|
||||
[
|
||||
'date' => '2024-02-23',
|
||||
'duration' => 14400, // in seconds
|
||||
'history' => [
|
||||
// duration in s of the 3h windows for the day starting at 00:00
|
||||
300,
|
||||
0,
|
||||
500,
|
||||
0,
|
||||
100,
|
||||
200,
|
||||
100,
|
||||
300,
|
||||
],
|
||||
],
|
||||
[
|
||||
'date' => '2024-02-22',
|
||||
'duration' => 18000, // in seconds
|
||||
'history' => [
|
||||
// duration in s of the 3h windows for the day starting at 00:00
|
||||
300,
|
||||
0,
|
||||
500,
|
||||
0,
|
||||
100,
|
||||
200,
|
||||
100,
|
||||
300,
|
||||
],
|
||||
],
|
||||
[
|
||||
'date' => '2024-02-21',
|
||||
'duration' => 21600, // in seconds
|
||||
'history' => [
|
||||
// duration in s of the 3h windows for the day starting at 00:00
|
||||
300,
|
||||
0,
|
||||
500,
|
||||
0,
|
||||
100,
|
||||
200,
|
||||
100,
|
||||
300,
|
||||
],
|
||||
],
|
||||
[
|
||||
'date' => '2024-02-20',
|
||||
'duration' => 25200, // in seconds
|
||||
'history' => [
|
||||
// duration in s of the 3h windows for the day starting at 00:00
|
||||
300,
|
||||
0,
|
||||
500,
|
||||
0,
|
||||
100,
|
||||
200,
|
||||
100,
|
||||
300,
|
||||
],
|
||||
],
|
||||
$timezone = $this->timezoneService->getTimezoneFromUser($user);
|
||||
$lastDaysSplitInWindows = $this->lastDaysSplitInWindows(7, $timezone, 8);
|
||||
$data = collect(DB::select('
|
||||
SELECT time_ranges.start, EXTRACT(epoch FROM sum(LEAST(time_ranges."end", coalesce(time_entries."end", :now::timestamp)) - GREATEST(time_ranges.start, time_entries.start))) AS aggregate
|
||||
FROM (
|
||||
SELECT time_range_starts.start AS start, time_range_starts.start + interval \'3 hours\' AS "end"
|
||||
FROM generate_series(:start_time_ranges::timestamp, :end_time_ranges::timestamp, interval \'3 hours\') as time_range_starts (start)
|
||||
) time_ranges
|
||||
JOIN time_entries ON time_entries.start < time_ranges."end"
|
||||
AND coalesce(time_entries."end", :now::timestamp) > time_ranges.start
|
||||
where time_entries.user_id = :user_id and
|
||||
time_entries.organization_id = :organization_id
|
||||
GROUP BY time_ranges.start
|
||||
ORDER BY time_ranges.start
|
||||
', [
|
||||
'start_time_ranges' => $lastDaysSplitInWindows['start'],
|
||||
'end_time_ranges' => $lastDaysSplitInWindows['end'],
|
||||
'user_id' => $user->getKey(),
|
||||
'organization_id' => $organization->getKey(),
|
||||
'now' => Carbon::now()->toDateTimeString(),
|
||||
]))->pluck('aggregate', 'start');
|
||||
|
||||
];
|
||||
$response = [];
|
||||
|
||||
foreach ($lastDaysSplitInWindows['dates'] as $date => $windows) {
|
||||
$history = [];
|
||||
$duration = 0;
|
||||
foreach ($windows as $window) {
|
||||
$value = (int) ($data->get($window, null) ?? 0);
|
||||
$history[] = $value;
|
||||
$duration += $value;
|
||||
}
|
||||
$response[] = [
|
||||
'date' => $date,
|
||||
'duration' => $duration,
|
||||
'history' => $history,
|
||||
];
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
37
database/factories/OrganizationInvitationFactory.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<OrganizationInvitation>
|
||||
*/
|
||||
class OrganizationInvitationFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'email' => $this->faker->unique()->safeEmail(),
|
||||
'role' => Role::Employee->value,
|
||||
'organization_id' => Organization::factory(),
|
||||
];
|
||||
}
|
||||
|
||||
public function forOrganization(Organization $organization): self
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'organization_id' => $organization->getKey(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ class ProjectMemberFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'billable_rate' => $this->faker->numberBetween(50, 1000) * 100,
|
||||
'billable_rate' => $this->faker->numberBetween(10, 10000) * 100,
|
||||
'project_id' => Project::factory(),
|
||||
'user_id' => User::factory(),
|
||||
];
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
|
||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||
@@ -15,5 +16,6 @@ return [
|
||||
TimeEntryCanNotBeRestartedApiException::KEY => 'Time entry is already stopped and can not be restarted',
|
||||
InactiveUserCanNotBeUsedApiException::KEY => 'Inactive user can not be used',
|
||||
UserIsAlreadyMemberOfProjectApiException::KEY => 'User is already a member of the project',
|
||||
EntityStillInUseApiException::KEY => 'The :modelToDelete is still used by a :modelInUse and can not be deleted.',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -202,4 +202,16 @@ return [
|
||||
'currency' => 'The :attribute field must be a valid currency code (ISO 4217).',
|
||||
'organization' => 'The :attribute does not exist.',
|
||||
'task_belongs_to_project' => 'The :attribute is not part of the given project.',
|
||||
|
||||
'entities' => [
|
||||
'organization' => 'organization',
|
||||
'project' => 'project',
|
||||
'task' => 'task',
|
||||
'time_entry' => 'time entry',
|
||||
'user' => 'user',
|
||||
'client' => 'client',
|
||||
'member' => 'member',
|
||||
'project_member' => 'project member',
|
||||
'tag' => 'tag',
|
||||
],
|
||||
];
|
||||
|
||||
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 15 KiB |
BIN
public/favicons/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/favicons/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
public/favicons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
9
public/favicons/browserconfig.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/favicons/mstile-150x150.png"/>
|
||||
<TileColor>#da532c</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
BIN
public/favicons/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 599 B |
BIN
public/favicons/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 781 B |
BIN
public/favicons/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/favicons/mstile-144x144.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/favicons/mstile-150x150.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
public/favicons/mstile-310x150.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
public/favicons/mstile-310x310.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/favicons/mstile-70x70.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
25
public/favicons/safari-pinned-tab.svg
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M3578 5835 c-1 -1 -61 -5 -133 -9 -135 -7 -196 -12 -255 -22 -19 -3
|
||||
-51 -8 -70 -10 -19 -3 -51 -9 -70 -14 -19 -5 -53 -12 -74 -16 -47 -7 -203 -59
|
||||
-296 -99 -444 -188 -719 -448 -855 -811 -25 -67 -63 -233 -70 -309 -6 -56 -6
|
||||
-315 -1 -345 2 -14 7 -50 11 -80 22 -176 93 -359 192 -498 213 -299 588 -491
|
||||
1338 -687 215 -56 395 -129 462 -188 41 -36 73 -95 75 -140 5 -87 -32 -146
|
||||
-117 -186 -114 -53 -385 -60 -627 -15 -97 17 -262 72 -367 121 -117 54 -362
|
||||
216 -389 257 -4 6 -21 24 -38 39 l-32 29 -225 -229 c-124 -125 -318 -322 -431
|
||||
-438 l-205 -210 57 -55 c292 -278 615 -463 1078 -618 111 -38 254 -78 314 -88
|
||||
14 -3 41 -9 60 -14 34 -8 137 -26 210 -35 180 -24 617 -24 805 0 712 92 1222
|
||||
453 1390 981 66 209 78 509 30 785 -11 64 -51 172 -97 264 -137 275 -327 450
|
||||
-685 629 -145 73 -485 191 -638 222 -93 18 -347 101 -433 140 -135 62 -187
|
||||
122 -185 217 1 80 73 152 180 183 59 17 295 24 373 10 132 -22 331 -86 435
|
||||
-138 73 -38 202 -133 286 -212 l73 -68 434 436 c389 391 433 438 421 453 -38
|
||||
47 -57 69 -85 97 -252 253 -524 409 -929 534 -254 79 -441 113 -710 127 -60 4
|
||||
-131 8 -157 10 -25 2 -48 2 -50 0z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
19
public/favicons/site.webmanifest
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "solidtime",
|
||||
"short_name": "solidtime",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicons/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/favicons/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import Dropdown from '@/Components/Dropdown.vue';
|
||||
import { colors } from '@/utils/color';
|
||||
|
||||
const model = defineModel<string>({ default: '' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-3">
|
||||
<Dropdown align="bottom">
|
||||
<template #trigger>
|
||||
<div
|
||||
:style="{
|
||||
backgroundColor: model,
|
||||
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(5px + var(--tw-ring-offset-width)) ${model}30`,
|
||||
}"
|
||||
class="w-4 h-4 rounded-full cursor-pointer"></div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="text-white grid grid-cols-6 gap-3 px-3 py-3">
|
||||
<div
|
||||
v-for="color in colors"
|
||||
:key="color"
|
||||
@click="model = color"
|
||||
:style="{
|
||||
backgroundColor: color,
|
||||
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) ${color}30`,
|
||||
}"
|
||||
class="w-4 h-4 rounded-full cursor-pointer"></div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -13,6 +13,7 @@ import { twMerge } from 'tailwind-merge';
|
||||
import Badge from '@/Components/Common/Badge.vue';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import ProjectColorSelector from '@/Components/Common/Project/ProjectColorSelector.vue';
|
||||
|
||||
const { createProject } = useProjectsStore();
|
||||
const { clients } = storeToRefs(useClientsStore());
|
||||
@@ -28,6 +29,11 @@ const project = ref<CreateProjectBody>({
|
||||
async function submit() {
|
||||
await createProject(project.value);
|
||||
show.value = false;
|
||||
project.value = {
|
||||
name: '',
|
||||
color: getRandomColor(),
|
||||
client_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
const projectNameInput = ref<HTMLInputElement | null>(null);
|
||||
@@ -54,14 +60,8 @@ const currentClientName = computed(() => {
|
||||
|
||||
<template #content>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="px-3">
|
||||
<div
|
||||
:style="{
|
||||
backgroundColor: project.color,
|
||||
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(5px + var(--tw-ring-offset-width)) ${project.color}30`,
|
||||
}"
|
||||
class="w-4 h-4 rounded-full"></div>
|
||||
</div>
|
||||
<ProjectColorSelector
|
||||
v-model="project.color"></ProjectColorSelector>
|
||||
<div class="col-span-6 sm:col-span-4 flex-1">
|
||||
<TextInput
|
||||
id="projectName"
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
<CardTitle :title="title" :icon="icon"></CardTitle>
|
||||
<div
|
||||
class="rounded-lg bg-card-background border border-card-border flex-1 flex items-stretch">
|
||||
<div class="w-full flex flex-col">
|
||||
<div
|
||||
class="w-full flex flex-col">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,17 @@
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Favicons -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicons/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicons/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicons/favicon-16x16.png">
|
||||
<link rel="manifest" href="/favicons/site.webmanifest">
|
||||
<link rel="mask-icon" href="/favicons/safari-pinned-tab.svg" color="#000000">
|
||||
<link rel="shortcut icon" href="/favicons/favicon.ico">
|
||||
<meta name="msapplication-TileColor" content="#da532c">
|
||||
<meta name="msapplication-config" content="/favicons/browserconfig.xml">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
<!-- Scripts -->
|
||||
@routes
|
||||
@vite(['resources/js/app.ts', "resources/js/Pages/{$page['component']}.vue"])
|
||||
|
||||
@@ -49,6 +49,7 @@ Route::middleware([
|
||||
Route::name('invitations.')->group(static function () {
|
||||
Route::get('/organizations/{organization}/invitations', [InvitationController::class, 'index'])->name('index');
|
||||
Route::post('/organizations/{organization}/invitations', [InvitationController::class, 'store'])->name('store');
|
||||
Route::delete('/organizations/{organization}/invitations/{invitation}', [InvitationController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// Project routes
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Tests\Unit\Endpoint\Api\V1;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Testing\Fluent\AssertableJson;
|
||||
use Laravel\Passport\Passport;
|
||||
|
||||
@@ -199,6 +200,27 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_fails_if_client_is_still_in_use_by_project(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'clients:delete',
|
||||
]);
|
||||
$client = Client::factory()->forOrganization($data->organization)->create();
|
||||
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.clients.destroy', [$data->organization->getKey(), $client->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(400);
|
||||
$response->assertJsonPath('message', 'The client is still used by a project and can not be deleted.');
|
||||
$this->assertDatabaseHas(Client::class, [
|
||||
'id' => $client->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_deletes_client(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -76,4 +76,52 @@ class InvitationEndpointTest extends ApiEndpointTestAbstract
|
||||
$this->assertEquals('test@asdf.at', $invitation->email);
|
||||
$this->assertEquals('employee', $invitation->role);
|
||||
}
|
||||
|
||||
public function test_delete_fails_if_user_has_no_permission_to_remove_invitations(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
$invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create();
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.invitations.destroy', [$data->organization->getKey(), $invitation->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_delete_fails_if_invitation_belongs_to_different_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'invitations:remove',
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
$invitation = OrganizationInvitation::factory()->create();
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.invitations.destroy', [$data->organization->getKey(), $invitation->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_delete_removes_invitation(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'invitations:remove',
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
$invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create();
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.invitations.destroy', [$data->organization->getKey(), $invitation->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(204);
|
||||
$this->assertNull(OrganizationInvitation::find($invitation->getKey()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ namespace Tests\Unit\Endpoint\Api\V1;
|
||||
|
||||
use App\Models\Membership;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use Laravel\Passport\Passport;
|
||||
|
||||
@@ -154,6 +157,47 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_fails_if_member_is_still_in_use_by_a_time_entry(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'members:delete',
|
||||
]);
|
||||
TimeEntry::factory()->forUser($data->user)->forOrganization($data->organization)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $data->member->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(400);
|
||||
$response->assertJsonPath('message', 'The member is still used by a time entry and can not be deleted.');
|
||||
$this->assertDatabaseHas(Membership::class, [
|
||||
'id' => $data->member->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_fails_if_member_is_still_in_use_by_a_project_member(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'members:delete',
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||
ProjectMember::factory()->forProject($project)->forUser($data->user)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $data->member->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(400);
|
||||
$response->assertJsonPath('message', 'The member is still used by a project member and can not be deleted.');
|
||||
$this->assertDatabaseHas(Membership::class, [
|
||||
'id' => $data->member->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_destroy_member_succeeds_if_data_is_valid(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -7,6 +7,8 @@ namespace Tests\Unit\Endpoint\Api\V1;
|
||||
use App\Models\Client;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use Laravel\Passport\Passport;
|
||||
|
||||
class ProjectEndpointTest extends ApiEndpointTestAbstract
|
||||
@@ -333,6 +335,48 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_fails_if_project_is_still_in_use_by_a_task(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'projects:delete',
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||
$task = Task::factory()->forProject($project)->forOrganization($data->organization)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.projects.destroy', [$data->organization->getKey(), $project->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(400);
|
||||
$response->assertJsonPath('message', 'The project is still used by a task and can not be deleted.');
|
||||
$this->assertDatabaseHas(Project::class, [
|
||||
'id' => $project->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_fails_if_project_is_still_in_use_by_a_time_entry(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'projects:delete',
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||
$timeEntry = TimeEntry::factory()->forProject($project)->forOrganization($data->organization)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.projects.destroy', [$data->organization->getKey(), $project->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(400);
|
||||
$response->assertJsonPath('message', 'The project is still used by a time entry and can not be deleted.');
|
||||
$this->assertDatabaseHas(Project::class, [
|
||||
'id' => $project->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_deletes_project(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Tests\Unit\Endpoint\Api\V1;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\Tag;
|
||||
use App\Models\TimeEntry;
|
||||
use Illuminate\Testing\Fluent\AssertableJson;
|
||||
use Laravel\Passport\Passport;
|
||||
|
||||
@@ -199,6 +200,29 @@ class TagEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_fails_if_tag_is_still_in_use_by_a_time_entry(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'tags:delete',
|
||||
]);
|
||||
$tag = Tag::factory()->forOrganization($data->organization)->create();
|
||||
TimeEntry::factory()->forUser($data->user)->forOrganization($data->organization)->create([
|
||||
'tags' => [$tag->getKey()],
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.tags.destroy', [$data->organization->getKey(), $tag->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(400);
|
||||
$response->assertJsonPath('message', 'The tag is still used by a time entry and can not be deleted.');
|
||||
$this->assertDatabaseHas(Tag::class, [
|
||||
'id' => $tag->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_deletes_tag(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Tests\Unit\Endpoint\Api\V1;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use Laravel\Passport\Passport;
|
||||
|
||||
class TaskEndpointTest extends ApiEndpointTestAbstract
|
||||
@@ -303,6 +304,27 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_fails_if_task_is_still_in_use_by_a_time_entry(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'tasks:delete',
|
||||
]);
|
||||
$task = Task::factory()->forOrganization($data->organization)->create();
|
||||
TimeEntry::factory()->forUser($data->user)->forTask($task)->forOrganization($data->organization)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.tasks.destroy', [$data->organization->getKey(), $task->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(400);
|
||||
$response->assertJsonPath('message', 'The task is still used by a time entry and can not be deleted.');
|
||||
$this->assertDatabaseHas(Task::class, [
|
||||
'id' => $task->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_delete_endpoint_fails_if_user_has_no_permission_to_delete_tasks(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Tests\Unit\Model;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
@@ -117,4 +118,30 @@ class TimeEntryModelTest extends ModelTestAbstract
|
||||
'start' => '2021-01-01 13:00:00',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_scope_has_tag_filter_by_tag(): void
|
||||
{
|
||||
// Arrange
|
||||
$tag1 = Tag::factory()->create();
|
||||
$tag2 = Tag::factory()->create();
|
||||
$timeEntry1 = TimeEntry::factory()->create([
|
||||
'tags' => [$tag1->getKey()],
|
||||
]);
|
||||
$timeEntry2 = TimeEntry::factory()->create([
|
||||
'tags' => [$tag2->getKey()],
|
||||
]);
|
||||
$timeEntry3 = TimeEntry::factory()->create([
|
||||
'tags' => ['something-else'],
|
||||
]);
|
||||
$timeEntry4 = TimeEntry::factory()->create([
|
||||
'tags' => null,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$result = TimeEntry::hasTag($tag1)->get();
|
||||
|
||||
// Assert
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertTrue($result->first()->is($timeEntry1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Tests\Unit\Model;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use App\Providers\Filament\AdminPanelProvider;
|
||||
@@ -92,6 +93,27 @@ class UserModelTest extends ModelTestAbstract
|
||||
$this->assertTrue($timeEntriesRel->first()->is($timeEntries->first()));
|
||||
}
|
||||
|
||||
public function test_it_has_many_project_members(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$projectMembers = ProjectMember::factory()->forUser($user)->createMany(3);
|
||||
$otherProjectMembers = ProjectMember::factory()->forUser($otherUser)->createMany(3);
|
||||
|
||||
// Act
|
||||
$user->refresh();
|
||||
$projectMembersRel = $user->projectMembers;
|
||||
|
||||
// Assert
|
||||
$this->assertNotNull($projectMembersRel);
|
||||
$this->assertCount(3, $projectMembersRel);
|
||||
$this->assertEqualsCanonicalizing(
|
||||
$projectMembers->pluck('id')->toArray(),
|
||||
$projectMembersRel->pluck('id')->toArray()
|
||||
);
|
||||
}
|
||||
|
||||
public function test_scope_active_returns_only_non_placeholder_users(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -532,4 +532,132 @@ class DashboardServiceTest extends TestCase
|
||||
],
|
||||
], $result);
|
||||
}
|
||||
|
||||
public function test_last_seven_days_returns_spend_time_in_the_last_seven_days_aggregated_in_three_hour_blocks(): void
|
||||
{
|
||||
// Arrange
|
||||
$now = Carbon::create(2024, 4, 17, 12, 0, 0, 'Europe/Vienna');
|
||||
$this->travelTo($now);
|
||||
$organization = Organization::factory()->create();
|
||||
$user = User::factory()->create([
|
||||
'timezone' => 'Europe/Vienna',
|
||||
]);
|
||||
$timeEntryOverWholePeriod = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([
|
||||
'start' => now('Europe/Vienna')->subDays(7)->startOfDay()->subMinute()->utc(),
|
||||
'end' => now('Europe/Vienna')->endOfDay()->addMinute()->utc(), // TODO: addMinute should not be necessary
|
||||
]);
|
||||
$timeEntryOverWholePeriodWithoutEnd = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([
|
||||
'start' => now('Europe/Vienna')->subDays(7)->startOfDay()->subMinute()->utc(),
|
||||
'end' => null,
|
||||
]);
|
||||
$timeEntry1Task1 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([
|
||||
'start' => now('Europe/Vienna')->subMinutes(30)->utc(),
|
||||
'end' => now('Europe/Vienna')->subMinutes(20)->utc(),
|
||||
]);
|
||||
|
||||
// Act
|
||||
$result = $this->dashboardService->lastSevenDays($user, $organization);
|
||||
|
||||
// Assert
|
||||
$this->assertSame([
|
||||
0 => [
|
||||
'date' => '2024-04-17',
|
||||
'duration' => 115800,
|
||||
'history' => [
|
||||
0 => 21600,
|
||||
1 => 21600,
|
||||
2 => 22200,
|
||||
3 => 18000,
|
||||
4 => 10800,
|
||||
5 => 10800,
|
||||
6 => 10800, // TODO
|
||||
7 => 0, // TODO
|
||||
],
|
||||
],
|
||||
1 => [
|
||||
'date' => '2024-04-16',
|
||||
'duration' => 172800,
|
||||
'history' => [
|
||||
0 => 21600,
|
||||
1 => 21600,
|
||||
2 => 21600,
|
||||
3 => 21600,
|
||||
4 => 21600,
|
||||
5 => 21600,
|
||||
6 => 21600,
|
||||
7 => 21600,
|
||||
],
|
||||
],
|
||||
2 => [
|
||||
'date' => '2024-04-15',
|
||||
'duration' => 172800,
|
||||
'history' => [
|
||||
0 => 21600,
|
||||
1 => 21600,
|
||||
2 => 21600,
|
||||
3 => 21600,
|
||||
4 => 21600,
|
||||
5 => 21600,
|
||||
6 => 21600,
|
||||
7 => 21600,
|
||||
],
|
||||
],
|
||||
3 => [
|
||||
'date' => '2024-04-14',
|
||||
'duration' => 172800,
|
||||
'history' => [
|
||||
0 => 21600,
|
||||
1 => 21600,
|
||||
2 => 21600,
|
||||
3 => 21600,
|
||||
4 => 21600,
|
||||
5 => 21600,
|
||||
6 => 21600,
|
||||
7 => 21600,
|
||||
],
|
||||
],
|
||||
4 => [
|
||||
'date' => '2024-04-13',
|
||||
'duration' => 172800,
|
||||
'history' => [
|
||||
0 => 21600,
|
||||
1 => 21600,
|
||||
2 => 21600,
|
||||
3 => 21600,
|
||||
4 => 21600,
|
||||
5 => 21600,
|
||||
6 => 21600,
|
||||
7 => 21600,
|
||||
],
|
||||
],
|
||||
5 => [
|
||||
'date' => '2024-04-12',
|
||||
'duration' => 172800,
|
||||
'history' => [
|
||||
0 => 21600,
|
||||
1 => 21600,
|
||||
2 => 21600,
|
||||
3 => 21600,
|
||||
4 => 21600,
|
||||
5 => 21600,
|
||||
6 => 21600,
|
||||
7 => 21600,
|
||||
],
|
||||
],
|
||||
6 => [
|
||||
'date' => '2024-04-11',
|
||||
'duration' => 172800,
|
||||
'history' => [
|
||||
0 => 21600,
|
||||
1 => 21600,
|
||||
2 => 21600,
|
||||
3 => 21600,
|
||||
4 => 21600,
|
||||
5 => 21600,
|
||||
6 => 21600,
|
||||
7 => 21600,
|
||||
],
|
||||
],
|
||||
], $result);
|
||||
}
|
||||
}
|
||||
|
||||