Added billable rates; Added project members; Added visibility to projects

This commit is contained in:
Constantin Graf
2024-03-28 18:50:04 +01:00
parent bb42b0940a
commit ba0212ea01
71 changed files with 3236 additions and 174 deletions

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Actions\Fortify;
use App\Enums\Weekday;
use App\Models\Organization;
use App\Models\User;
use App\Service\TimezoneService;
@@ -60,6 +61,7 @@ class CreateNewUser implements CreatesNewUsers
'email' => $input['email'],
'password' => Hash::make($input['password']),
'timezone' => $timezone,
'week_start' => Weekday::Monday,
]), function (User $user) {
$this->createTeam($user);
});

View File

@@ -6,6 +6,7 @@ namespace App\Actions\Jetstream;
use App\Models\Organization;
use App\Models\User;
use App\Rules\CurrencyRule;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
@@ -27,11 +28,21 @@ class UpdateOrganization implements UpdatesTeamNames
Gate::forUser($user)->authorize('update', $organization);
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'name' => [
'required',
'string',
'max:255',
],
'currency' => [
'required',
'string',
new CurrencyRule(),
],
])->validateWithBag('updateTeamName');
$organization->forceFill([
'name' => $input['name'],
'currency' => $input['currency'],
])->save();
}
}

View File

@@ -23,7 +23,5 @@ class Kernel extends ConsoleKernel
protected function commands(): void
{
$this->load(__DIR__.'/Commands');
require base_path('routes/console.php');
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class TimeEntryCanNotBeRestartedApiException extends ApiException
{
public const string KEY = 'time_entry_can_not_be_restarted';
}

View File

@@ -5,18 +5,29 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Models\Organization;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Auth;
class Controller extends \App\Http\Controllers\Controller
{
public function __construct(
protected PermissionStore $permissionStore,
) {
}
/**
* @throws AuthorizationException
*/
protected function checkPermission(Organization $organization, string $permission): void
{
if (! Auth::user()->hasTeamPermission($organization, $permission)) {
if (! $this->permissionStore->has($organization, $permission)) {
throw new AuthorizationException();
}
}
protected function hasPermission(Organization $organization, string $permission): bool
{
return $this->permissionStore->has($organization, $permission);
}
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\UserNotPlaceholderApiException;
use App\Http\Requests\V1\User\UserIndexRequest;
use App\Http\Requests\V1\Member\MemberIndexRequest;
use App\Http\Resources\V1\User\MemberCollection;
use App\Http\Resources\V1\User\MemberResource;
use App\Models\Organization;
@@ -24,14 +24,14 @@ class MemberController extends Controller
*
* @throws AuthorizationException
*/
public function index(Organization $organization, UserIndexRequest $request): MemberCollection
public function index(Organization $organization, MemberIndexRequest $request): MemberCollection
{
$this->checkPermission($organization, 'users:view');
$this->checkPermission($organization, 'members:view');
$users = $organization->users()
$members = $organization->users()
->paginate();
return MemberCollection::make($users);
return MemberCollection::make($members);
}
/**
@@ -41,7 +41,7 @@ class MemberController extends Controller
*/
public function invitePlaceholder(Organization $organization, User $user, Request $request): JsonResponse
{
$this->checkPermission($organization, 'users:invite-placeholder');
$this->checkPermission($organization, 'members:invite-placeholder');
if (! $user->is_placeholder) {
throw new UserNotPlaceholderApiException();

View File

@@ -33,6 +33,7 @@ class OrganizationController extends Controller
$this->checkPermission($organization, 'organizations:update');
$organization->name = $request->input('name');
$organization->billable_rate = $request->input('billable_rate');
$organization->save();
return new OrganizationResource($organization);

View File

@@ -10,9 +10,12 @@ 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\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Auth;
class ProjectController extends Controller
{
@@ -36,9 +39,23 @@ class ProjectController extends Controller
public function index(Organization $organization): ProjectCollection
{
$this->checkPermission($organization, 'projects:view');
$projects = Project::query()
->whereBelongsTo($organization, 'organization')
->paginate();
$canViewAllProjects = $this->hasPermission($organization, 'projects:view:all');
/** @var User $user */
$user = Auth::user();
$projectsQuery = Project::query()
->whereBelongsTo($organization, 'organization');
if (! $canViewAllProjects) {
$projectsQuery->where(function (Builder $builder) use ($user): Builder {
return $builder->where('is_public', '=', true)
->orWhereHas('members', function (Builder $builder) use ($user): Builder {
return $builder->whereBelongsTo($user, 'user');
});
});
}
$projects = $projectsQuery->paginate();
return new ProjectCollection($projects);
}
@@ -72,6 +89,7 @@ class ProjectController extends Controller
$project = new Project();
$project->name = $request->input('name');
$project->color = $request->input('color');
$project->billable_rate = $request->input('billable_rate');
$project->client_id = $request->input('client_id');
$project->organization()->associate($organization);
$project->save();
@@ -91,7 +109,8 @@ class ProjectController extends Controller
$this->checkPermission($organization, 'projects:update', $project);
$project->name = $request->input('name');
$project->color = $request->input('color');
$project->client_id = $request->input('project_id');
$project->billable_rate = $request->input('billable_rate');
$project->client_id = $request->input('client_id');
$project->save();
return new ProjectResource($project);

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Requests\V1\ProjectMember\ProjectMemberStoreRequest;
use App\Http\Requests\V1\ProjectMember\ProjectMemberUpdateRequest;
use App\Http\Resources\V1\ProjectMember\ProjectMemberCollection;
use App\Http\Resources\V1\ProjectMember\ProjectMemberResource;
use App\Models\Organization;
use App\Models\Project;
use App\Models\ProjectMember;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
class ProjectMemberController extends Controller
{
protected function checkPermission(Organization $organization, string $permission, ?Project $project = null, ?ProjectMember $projectMember = null): void
{
parent::checkPermission($organization, $permission);
if ($project !== null && $project->organization_id !== $organization->id) {
throw new AuthorizationException('Project does not belong to organization');
}
if ($projectMember !== null && $projectMember->project->organization_id !== $organization->id) {
throw new AuthorizationException('Project member does not belong to organization');
}
}
/**
* Get project members for project
*
* @return ProjectMemberCollection<ProjectMemberResource>
*
* @throws AuthorizationException
*
* @operationId getProjectMembers
*/
public function index(Organization $organization, Project $project): ProjectMemberCollection
{
$this->checkPermission($organization, 'project-members:view', $project);
$projectMembers = ProjectMember::query()
->whereBelongsTo($project, 'project')
->paginate();
return new ProjectMemberCollection($projectMembers);
}
/**
* Add project member to project
*
* @throws AuthorizationException
*
* @operationId createProjectMember
*/
public function store(Organization $organization, Project $project, ProjectMemberStoreRequest $request): JsonResource
{
$this->checkPermission($organization, 'project-members:create', $project);
$projectMember = new ProjectMember();
$projectMember->user_id = $request->input('user_id');
$projectMember->billable_rate = $request->input('billable_rate');
$projectMember->project()->associate($project);
$projectMember->save();
return new ProjectMemberResource($projectMember);
}
/**
* Update project member
*
* @throws AuthorizationException
*
* @operationId updateProjectMember
*/
public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request): JsonResource
{
$this->checkPermission($organization, 'project-members:update', projectMember: $projectMember);
$projectMember->billable_rate = $request->input('billable_rate');
$projectMember->save();
return new ProjectMemberResource($projectMember);
}
/**
* Delete project member
*
* @throws AuthorizationException
*
* @operationId deleteProjectMember
*/
public function destroy(Organization $organization, ProjectMember $projectMember): JsonResponse
{
$this->checkPermission($organization, 'project-members:delete', projectMember: $projectMember);
$projectMember->delete();
return response()
->json(null, 204);
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest;
@@ -71,6 +72,7 @@ class TimeEntryController extends Controller
$timeEntries = $timeEntriesQuery->get();
if ($timeEntries->count() === $limit && $request->has('only_full_dates') && (bool) $request->get('only_full_dates') === true) {
// TODO: handle user timezone!
$lastDate = null;
/** @var TimeEntry $timeEntry */
foreach ($timeEntries as $timeEntry) {
@@ -125,6 +127,7 @@ class TimeEntryController extends Controller
$timeEntry->fill($request->validated());
$timeEntry->description = $request->get('description') ?? '';
$timeEntry->organization()->associate($organization);
$timeEntry->setComputedAttributeValue('billable_rate');
$timeEntry->save();
return new TimeEntryResource($timeEntry);
@@ -133,7 +136,7 @@ class TimeEntryController extends Controller
/**
* Update time entry
*
* @throws AuthorizationException
* @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException
*
* @operationId updateTimeEntry
*/
@@ -145,7 +148,9 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
}
// TODO: TimeEntryStillRunningApiException
if ($timeEntry->end !== null && $request->has('end') && $request->get('end') === null) {
throw new TimeEntryCanNotBeRestartedApiException();
}
$timeEntry->fill($request->validated());
$timeEntry->description = $request->get('description', $timeEntry->description) ?? '';

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Models\Organization;
use App\Models\User;
use App\Service\DashboardService;
use Illuminate\Support\Str;
@@ -16,27 +17,17 @@ class DashboardController extends Controller
{
/** @var User $user */
$user = auth()->user();
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, 60);
$weeklyHistory = $dashboardService->getWeeklyHistory($user);
/** @var Organization $organization */
$organization = $user->currentTeam;
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
$weeklyHistory = $dashboardService->getWeeklyHistory($user, $organization);
$totalWeeklyTime = $dashboardService->totalWeeklyTime($user, $organization);
$totalWeeklyBillableTime = $dashboardService->totalWeeklyBillableTime($user, $organization);
$totalWeeklyBillableAmount = $dashboardService->totalWeeklyBillableAmount($user, $organization);
$weeklyProjectOverview = $dashboardService->weeklyProjectOverview($user, $organization);
return Inertia::render('Dashboard', [
'weeklyProjectOverview' => [
[
'value' => 120,
'name' => 'Project 11',
'color' => '#26a69a',
],
[
'value' => 200,
'name' => 'Project 2',
'color' => '#d4e157',
],
[
'value' => 150,
'name' => 'Project 3',
'color' => '#ff7043',
],
],
'weeklyProjectOverview' => $weeklyProjectOverview,
'latestTasks' => [
// the 4 tasks with the most recent time entries
[
@@ -210,12 +201,9 @@ class DashboardController extends Controller
],
],
'dailyTrackedHours' => $dailyTrackedHours,
'totalWeeklyTime' => 400,
'totalWeeklyBillableTime' => 300,
'totalWeeklyBillableAmount' => [
'value' => 300.5,
'currency' => 'USD',
],
'totalWeeklyTime' => $totalWeeklyTime,
'totalWeeklyBillableTime' => $totalWeeklyBillableTime,
'totalWeeklyBillableAmount' => $totalWeeklyBillableAmount,
'weeklyHistory' => $weeklyHistory,
]);
}

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Http\Requests\V1\User;
namespace App\Http\Requests\V1\Member;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
@@ -11,7 +11,7 @@ use Illuminate\Foundation\Http\FormRequest;
/**
* @property Organization $organization
*/
class UserIndexRequest extends FormRequest
class MemberIndexRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -26,6 +26,11 @@ class OrganizationUpdateRequest extends FormRequest
'string',
'max:255',
],
'billable_rate' => [
'nullable',
'integer',
'min:0',
],
];
}
}

View File

@@ -38,6 +38,11 @@ class ProjectStoreRequest extends FormRequest
'max:255',
new ColorRule(),
],
'billable_rate' => [
'nullable',
'integer',
'min:0',
],
'client_id' => [
'nullable',
new ExistsEloquent(Client::class, null, function (Builder $builder): Builder {

View File

@@ -37,6 +37,11 @@ class ProjectUpdateRequest extends FormRequest
'max:255',
new ColorRule(),
],
'billable_rate' => [
'nullable',
'integer',
'min:0',
],
'client_id' => [
'nullable',
new ExistsEloquent(Client::class, null, function (Builder $builder): Builder {

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\ProjectMember;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class ProjectMemberStoreRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'user_id' => [
'required',
'uuid',
new ExistsEloquent(User::class, null, function (Builder $builder): Builder {
/** @var Builder<User> $builder */
return $builder->belongsToOrganization($this->organization);
}),
],
'billable_rate' => [
'nullable',
'integer',
'min:0',
],
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\ProjectMember;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* @property Organization $organization Organization from model binding
*/
class ProjectMemberUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'billable_rate' => [
'nullable',
'integer',
'min:0',
],
];
}
}

View File

@@ -5,11 +5,8 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Task;
use App\Models\Organization;
use App\Models\Project;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization Organization from model binding
@@ -31,12 +28,6 @@ class TaskUpdateRequest extends FormRequest
'min:1',
'max:255',
],
'project_id' => [
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
];
}
}

View File

@@ -27,6 +27,8 @@ class OrganizationResource extends BaseResource
'name' => $this->resource->name,
/** @var string $color Personal organizations automatically created after registration */
'is_personal' => $this->resource->personal_team,
/** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->resource->billable_rate,
];
}
}

View File

@@ -29,6 +29,8 @@ class ProjectResource extends BaseResource
'color' => $this->resource->color,
/** @var string|null $client_id ID of client */
'client_id' => $this->resource->client_id,
/** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->resource->billable_rate,
];
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\ProjectMember;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ProjectMemberCollection extends ResourceCollection implements PaginatedResourceCollection
{
/**
* The resource that this resource collects.
*
* @var string
*/
public $collects = ProjectMemberResource::class;
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\ProjectMember;
use App\Http\Resources\V1\BaseResource;
use App\Models\ProjectMember;
use Illuminate\Http\Request;
/**
* @property ProjectMember $resource
*/
class ProjectMemberResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, string|bool|int|null>
*/
public function toArray(Request $request): array
{
return [
/** @var string $id ID of project member */
'id' => $this->resource->id,
/** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->resource->billable_rate,
/** @var string $user_id ID of the user */
'user_id' => $this->resource->user_id,
/** @var string $project_id ID of the project */
'project_id' => $this->resource->project_id,
];
}
}

View File

@@ -35,6 +35,8 @@ class MemberResource extends BaseResource
'role' => $membership->role,
/** @var bool $is_placeholder Placeholder user for imports, user might not really exist and does not know about this placeholder membership */
'is_placeholder' => $this->resource->is_placeholder,
/** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $membership->billable_rate,
];
}
}

View File

@@ -10,6 +10,7 @@ use Laravel\Jetstream\Membership as JetstreamMembership;
/**
* @property string $id
* @property string $role
* @property int|null $billable_rate
* @property string $organization_id
* @property string $user_id
* @property string $created_at

View File

@@ -20,6 +20,8 @@ use Laravel\Jetstream\Team as JetstreamTeam;
* @property string $id
* @property string $name
* @property bool $personal_team
* @property string $currency
* @property int|null $billable_rate
* @property User $owner
* @property Collection<User> $users
* @property Collection<string, User> $realUsers
@@ -40,6 +42,7 @@ class Organization extends JetstreamTeam
protected $casts = [
'name' => 'string',
'personal_team' => 'boolean',
'currency' => 'string',
];
/**
@@ -63,6 +66,15 @@ class Organization extends JetstreamTeam
'deleted' => TeamDeleted::class,
];
/**
* The model's default values for attributes.
*
* @var array<string, mixed>
*/
protected $attributes = [
'currency' => 'EUR',
];
/**
* Get all the non-placeholder users of the organization including its owner.
*
@@ -88,7 +100,10 @@ class Organization extends JetstreamTeam
public function users(): BelongsToMany
{
return $this->belongsToMany(Jetstream::userModel(), Jetstream::membershipModel())
->withPivot('role')
->withPivot([
'role',
'billable_rate',
])
->withTimestamps()
->as('membership');
}

View File

@@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property string $color
* @property string $organization_id
* @property string $client_id
* @property int|null $billable_rate
* @property-read Organization $organization
* @property-read Client|null $client
* @property-read Collection<Task> $tasks
@@ -55,6 +56,14 @@ class Project extends Model
return $this->belongsTo(Client::class, 'client_id');
}
/**
* @return HasMany<ProjectMember>
*/
public function members(): HasMany
{
return $this->hasMany(ProjectMember::class);
}
/**
* @return HasMany<Task>
*/

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Database\Factories\ProjectMemberFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property string $id
* @property int|null $billable_rate
* @property string $project_id
* @property string $user_id
* @property-read Project $project
* @property-read User $user
*
* @method static ProjectMemberFactory factory()
*/
class ProjectMember extends Model
{
use HasFactory;
use HasUuids;
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'billable_rate' => 'int',
];
/**
* @return BelongsTo<Project, ProjectMember>
*/
public function project(): BelongsTo
{
return $this->belongsTo(Project::class, 'project_id');
}
/**
* @return BelongsTo<User, ProjectMember>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models;
use App\Service\BillableRateService;
use Carbon\CarbonInterval;
use Database\Factories\TimeEntryFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
@@ -11,12 +12,14 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use Korridor\LaravelComputedAttributes\ComputedAttributes;
/**
* @property string $id
* @property string $description
* @property Carbon $start
* @property Carbon|null $end
* @property int $billable_rate Billable rate per hour in cents
* @property bool $billable
* @property array $tags
* @property string $user_id
@@ -32,6 +35,7 @@ use Illuminate\Support\Carbon;
*/
class TimeEntry extends Model
{
use ComputedAttributes;
use HasFactory;
use HasUuids;
@@ -46,8 +50,24 @@ class TimeEntry extends Model
'end' => 'datetime',
'billable' => 'bool',
'tags' => 'array',
'billable_rate' => 'int',
];
/**
* The attributes that are computed. (f.e. for performance reasons)
* These attributes can be regenerated at any time.
*
* @var string[]
*/
protected array $computed = [
'billable_rate',
];
public function getBillableRateComputed(): ?int
{
return app(BillableRateService::class)->getBillableRateForTimeEntry($this);
}
public function getDuration(): ?CarbonInterval
{
return $this->end === null ? null : $this->start->diffAsCarbonInterval($this->end);

View File

@@ -115,6 +115,7 @@ class User extends Authenticatable
return $this->belongsToMany(Organization::class, Membership::class)
->withPivot([
'role',
'billable_rate',
])
->withTimestamps()
->as('membership');

View File

@@ -13,6 +13,7 @@ use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Models\User;
use App\Service\PermissionStore;
use Dedoc\Scramble\Scramble;
use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\SecurityScheme;
@@ -20,6 +21,7 @@ use Dedoc\Scramble\Support\Generator\SecuritySchemes\OAuthFlow;
use Filament\Forms\Components\Section;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
@@ -77,5 +79,9 @@ class AppServiceProvider extends ServiceProvider
URL::forceScheme('https');
request()->server->set('HTTPS', request()->header('X-Forwarded-Proto', 'https') === 'https' ? 'on' : 'off');
}
$this->app->scoped(PermissionStore::class, function (Application $app): PermissionStore {
return new PermissionStore();
});
}
}

View File

@@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\ServiceProvider;
class BroadcastServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Broadcast::routes();
require base_path('routes/channels.php');
}
}

View File

@@ -15,6 +15,8 @@ use App\Enums\Weekday;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Service\TimezoneService;
use Brick\Money\Currency;
use Brick\Money\ISOCurrencyProvider;
use Illuminate\Http\Request;
use Illuminate\Support\ServiceProvider;
use Laravel\Jetstream\Jetstream;
@@ -56,9 +58,14 @@ class JetstreamServiceProvider extends ServiceProvider
Jetstream::role('admin', 'Administrator', [
'projects:view',
'projects:view:all',
'projects:create',
'projects:update',
'projects:delete',
'project-members:view',
'project-members:create',
'project-members:update',
'project-members:delete',
'tasks:view',
'tasks:create',
'tasks:update',
@@ -82,15 +89,20 @@ class JetstreamServiceProvider extends ServiceProvider
'organizations:view',
'organizations:update',
'import',
'users:invite-placeholder',
'users:view',
'members:view',
'members:invite-placeholder',
])->description('Administrator users can perform any action.');
Jetstream::role('manager', 'Manager', [
'projects:view',
'projects:view:all',
'projects:create',
'projects:update',
'projects:delete',
'project-members:view',
'project-members:create',
'project-members:update',
'project-members:delete',
'tasks:view',
'tasks:create',
'tasks:update',
@@ -108,7 +120,7 @@ class JetstreamServiceProvider extends ServiceProvider
'tags:update',
'tags:delete',
'organizations:view',
'users:view',
'members:view',
])->description('Managers have the ability to read, create, and update their own time entries as well as those of their team.');
Jetstream::role('employee', 'Employee', [
@@ -125,14 +137,25 @@ class JetstreamServiceProvider extends ServiceProvider
Jetstream::role('placeholder', 'Placeholder', [
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');
Jetstream::inertia()->whenRendering(
'Profile/Show',
function (Request $request, array $data) {
return array_merge($data, [
'timezones' => $this->app->get(TimezoneService::class)->getSelectOptions(),
'weekdays' => Weekday::toSelectArray(),
]);
}
);
Jetstream::inertia()
->whenRendering(
'Profile/Show',
function (Request $request, array $data): array {
return array_merge($data, [
'timezones' => $this->app->get(TimezoneService::class)->getSelectOptions(),
'weekdays' => Weekday::toSelectArray(),
]);
}
)
->whenRendering(
'Teams/Show',
function (Request $request, array $data): array {
return array_merge($data, [
'currencies' => array_map(function (Currency $currency): string {
return $currency->getName();
}, ISOCurrencyProvider::getInstance()->getAvailableCurrencies()),
]);
}
);
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Rules;
use Brick\Money\ISOCurrencyProvider;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;
class CurrencyRule implements ValidationRule
{
/**
* Run the validation rule.
*
* @param Closure(string): PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value)) {
$fail(__('validation.string'));
return;
}
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
if (array_key_exists($value, $currencies)) {
return;
}
$fail(__('validation.currency'));
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Models\Membership;
use App\Models\Organization;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
class BillableRateService
{
public function getBillableRateForTimeEntry(TimeEntry $timeEntry): ?int
{
if (! $timeEntry->billable) {
return null;
}
if ($timeEntry->project_id !== null) {
// Project member rate
/** @var ProjectMember|null $projectMember */
$projectMember = ProjectMember::query()
->where('user_id', '=', $timeEntry->user_id)
->where('project_id', '=', $timeEntry->project_id)
->first();
if ($projectMember !== null && $projectMember->billable_rate !== null) {
return $projectMember->billable_rate;
}
// Project rate
/** @var Project|null $project */
$project = Project::find($timeEntry->project_id);
if ($project !== null && $project->billable_rate !== null) {
return $project->billable_rate;
}
}
// Member rate
/** @var Membership|null $membership */
$membership = Membership::query()
->where('user_id', '=', $timeEntry->user_id)
->where('organization_id', '=', $timeEntry->organization_id)
->first();
if ($membership !== null && $membership->billable_rate !== null) {
return $membership->billable_rate;
}
// Organization rate
/** @var Organization|null $organization */
$organization = Organization::query()
->where('id', '=', $timeEntry->organization_id)
->first();
if ($organization !== null && $organization->billable_rate !== null) {
return $organization->billable_rate;
}
return null;
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service;
use App\Enums\Weekday;
use App\Models\Organization;
use App\Models\TimeEntry;
use App\Models\User;
use Carbon\Carbon;
@@ -86,7 +87,7 @@ class DashboardService
*
* @return array<int, array{date: string, duration: int}>
*/
public function getDailyTrackedHours(User $user, int $days): array
public function getDailyTrackedHours(User $user, Organization $organization, int $days): array
{
$timezone = $this->timezoneService->getTimezoneFromUser($user);
$timezoneShift = $this->timezoneService->getShiftFromUtc($timezone);
@@ -103,7 +104,8 @@ class DashboardService
$query = TimeEntry::query()
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
->where('user_id', '=', $user->id)
->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey())
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')'))
->orderBy('date');
@@ -128,7 +130,7 @@ class DashboardService
*
* @return array<int, array{date: string, duration: int}>
*/
public function getWeeklyHistory(User $user): array
public function getWeeklyHistory(User $user, Organization $organization): array
{
$timezone = $this->timezoneService->getTimezoneFromUser($user);
$timezoneShift = $this->timezoneService->getShiftFromUtc($timezone);
@@ -143,7 +145,8 @@ class DashboardService
$query = TimeEntry::query()
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
->where('user_id', '=', $user->id)
->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey())
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')'))
->orderBy('date');
@@ -162,4 +165,92 @@ class DashboardService
return $result;
}
public function totalWeeklyTime(User $user, Organization $organization): int
{
$timezone = $this->timezoneService->getTimezoneFromUser($user);
$possibleDays = $this->daysOfThisWeek($timezone, $user->week_start);
$query = TimeEntry::query()
->select(DB::raw('round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey());
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
/** @var Collection<int, object{aggregate: int}> $resultDb */
$resultDb = $query->get();
return (int) $resultDb->get(0)->aggregate;
}
public function totalWeeklyBillableTime(User $user, Organization $organization): int
{
$timezone = $this->timezoneService->getTimezoneFromUser($user);
$possibleDays = $this->daysOfThisWeek($timezone, $user->week_start);
$query = TimeEntry::query()
->select(DB::raw('round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
->where('billable', '=', true)
->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey());
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
/** @var Collection<int, object{aggregate: int}> $resultDb */
$resultDb = $query->get();
return (int) $resultDb->get(0)->aggregate;
}
/**
* @return array{value: int, currency: string}
*/
public function totalWeeklyBillableAmount(User $user, Organization $organization): array
{
$timezone = $this->timezoneService->getTimezoneFromUser($user);
$possibleDays = $this->daysOfThisWeek($timezone, $user->week_start);
$query = TimeEntry::query()
->select(DB::raw('
round(
sum(
extract(epoch from (coalesce("end", now()) - start)) * (billable_rate::float/60/60)
)
) as aggregate'))
->where('billable', '=', true)
->whereNotNull('billable_rate')
->where('user_id', '=', $user->id);
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
/** @var Collection<int, object{aggregate: int}> $resultDb */
$resultDb = $query->get();
return [
'value' => (int) $resultDb->get(0)->aggregate,
'currency' => $organization->currency,
];
}
/**
* @return array<int, array{value: int, name: string, color: string}>
*/
public function weeklyProjectOverview(User $user, Organization $organization): array
{
return [
[
'value' => 120,
'name' => 'Project 11',
'color' => '#26a69a',
],
[
'value' => 200,
'name' => 'Project 2',
'color' => '#d4e157',
],
[
'value' => 150,
'name' => 'Project 3',
'color' => '#ff7043',
],
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
class PermissionStore
{
/**
* @var array<string, array<string>>
*/
private array $permissionCache = [];
public function has(Organization $organization, string $permission): bool
{
/** @var User|null $user */
$user = Auth::user();
if ($user === null) {
return false;
}
if (! isset($this->permissionCache[$user->getKey().'|'.$organization->getKey()])) {
if ($user->ownsTeam($organization)) {
return true;
}
if (! $user->belongsToTeam($organization)) {
return false;
}
$permissions = $user->teamPermissions($organization);
$this->permissionCache[$user->getKey().'|'.$organization->getKey()] = $permissions;
} else {
$permissions = $this->permissionCache[$user->getKey().'|'.$organization->getKey()];
}
return in_array($permission, $permissions, true);
}
}

View File

@@ -5,9 +5,8 @@ declare(strict_types=1);
namespace App\Service;
use App\Models\User;
use Carbon\Carbon;
use Carbon\CarbonTimeZone;
use DateTime;
use DateTimeZone;
use Illuminate\Support\Facades\Log;
class TimezoneService
@@ -17,9 +16,7 @@ class TimezoneService
*/
public function getTimezones(): array
{
$tzlist = CarbonTimeZone::listIdentifiers(DateTimeZone::ALL);
return $tzlist;
return CarbonTimeZone::listIdentifiers();
}
public function getTimezoneFromUser(User $user): CarbonTimeZone
@@ -57,8 +54,6 @@ class TimezoneService
public function getShiftFromUtc(CarbonTimeZone $timeZone): int
{
$timezoneShift = $timeZone->getOffset(new DateTime('now', new DateTimeZone('UTC')));
return $timezoneShift;
return $timeZone->getOffset(Carbon::now());
}
}