mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 16:22:16 +01:00
Compare commits
4 Commits
feature/co
...
feature/de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
326b76a07f | ||
|
|
af5e894c83 | ||
|
|
ba866751ff | ||
|
|
c8d6ad734e |
@@ -28,7 +28,7 @@ class Kernel extends ConsoleKernel
|
||||
|
||||
$schedule->command('self-host:database-consistency')
|
||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
|
||||
->everySixHours();
|
||||
->twiceDaily();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class InvitationForTheEmailAlreadyExistsApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'invitation_for_the_email_already_exists';
|
||||
}
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
|
||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||
use App\Http\Requests\V1\Invitation\InvitationIndexRequest;
|
||||
use App\Http\Requests\V1\Invitation\InvitationStoreRequest;
|
||||
@@ -51,7 +50,6 @@ class InvitationController extends Controller
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws UserIsAlreadyMemberOfOrganizationApiException
|
||||
* @throws InvitationForTheEmailAlreadyExistsApiException
|
||||
*
|
||||
* @operationId invite
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,6 @@ use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
||||
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
|
||||
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
||||
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
|
||||
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
||||
@@ -174,7 +173,6 @@ class MemberController extends Controller
|
||||
* @throws UserNotPlaceholderApiException
|
||||
* @throws UserIsAlreadyMemberOfOrganizationApiException
|
||||
* @throws ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException
|
||||
* @throws InvitationForTheEmailAlreadyExistsApiException
|
||||
*
|
||||
* @operationId invitePlaceholder
|
||||
*/
|
||||
|
||||
@@ -43,10 +43,7 @@ class Controller extends BaseController
|
||||
/** @var Member|null $member */
|
||||
$member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first();
|
||||
if ($member === null) {
|
||||
Log::error('This function should only be called in authenticated context after checking the user is a member of the organization', [
|
||||
'user' => $user->getKey(),
|
||||
'organization' => $organization->getKey(),
|
||||
]);
|
||||
Log::error('This function should only be called in authenticated context after checking the user is a member of the organization');
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,11 @@ namespace App\Http\Requests\V1\Invitation;
|
||||
use App\Enums\Role;
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
|
||||
/**
|
||||
* @property Organization $organization
|
||||
@@ -26,6 +29,10 @@ class InvitationStoreRequest extends BaseFormRequest
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
UniqueEloquent::make(OrganizationInvitation::class, 'email', function (Builder $builder): Builder {
|
||||
/** @var Builder<OrganizationInvitation> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->withCustomTranslation('validation.invitation_already_exists'),
|
||||
],
|
||||
'role' => [
|
||||
'required',
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
|
||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||
use App\Mail\OrganizationInvitationMail;
|
||||
use App\Models\Member;
|
||||
@@ -17,7 +16,7 @@ use Laravel\Jetstream\Events\InvitingTeamMember;
|
||||
class InvitationService
|
||||
{
|
||||
/**
|
||||
* @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException
|
||||
* @throws UserIsAlreadyMemberOfOrganizationApiException
|
||||
*/
|
||||
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
|
||||
{
|
||||
@@ -29,13 +28,6 @@ class InvitationService
|
||||
throw new UserIsAlreadyMemberOfOrganizationApiException;
|
||||
}
|
||||
|
||||
if (OrganizationInvitation::query()
|
||||
->where('email', $email)
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->exists()) {
|
||||
throw new InvitationForTheEmailAlreadyExistsApiException;
|
||||
}
|
||||
|
||||
InvitingTeamMember::dispatch($organization, $email, $role->value);
|
||||
|
||||
$invitation = new OrganizationInvitation;
|
||||
|
||||
@@ -67,14 +67,6 @@ class MemberService
|
||||
throw new CanNotRemoveOwnerFromOrganization;
|
||||
}
|
||||
|
||||
$user = $member->user;
|
||||
$isPlaceholder = $user->is_placeholder;
|
||||
|
||||
if (! $isPlaceholder && $user->current_team_id === $member->organization_id) {
|
||||
$user->currentTeam()->disassociate();
|
||||
$user->save();
|
||||
}
|
||||
|
||||
if ($withRelations) {
|
||||
TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->delete();
|
||||
ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->delete();
|
||||
@@ -88,14 +80,6 @@ class MemberService
|
||||
}
|
||||
|
||||
$member->delete();
|
||||
|
||||
if ($isPlaceholder) {
|
||||
$user->delete();
|
||||
} else {
|
||||
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
|
||||
$this->userService->makeSureUserHasCurrentOrganization($user);
|
||||
}
|
||||
|
||||
MemberRemoved::dispatch($member, $organization);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
||||
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
|
||||
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
|
||||
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
||||
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
|
||||
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
|
||||
@@ -46,7 +45,6 @@ return [
|
||||
ChangingRoleOfPlaceholderIsNotAllowed::KEY => 'Changing role of placeholder is not allowed',
|
||||
OnlyPlaceholdersCanBeMergedIntoAnotherMember::KEY => 'Only placeholders can be merged into another member',
|
||||
ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',
|
||||
InvitationForTheEmailAlreadyExistsApiException::KEY => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
|
||||
],
|
||||
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
|
||||
];
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
TooltipComponent,
|
||||
} from 'echarts/components';
|
||||
import type { AggregatedTimeEntries, Organization } from '@/packages/api/src';
|
||||
import { useCssVariable } from '@/utils/useCssVariable';
|
||||
import { useCssVar } from '@vueuse/core';
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
@@ -47,10 +47,8 @@ const xAxisLabels = computed(() => {
|
||||
formatDate(el.key ?? '', organization?.value?.date_format)
|
||||
);
|
||||
});
|
||||
const accentColor = useCssVariable('--theme-color-chart');
|
||||
const labelColor = useCssVariable('--color-text-secondary');
|
||||
const markLineColor = useCssVariable('--color-border-secondary');
|
||||
const splitLineColor = useCssVariable('--color-border-tertiary');
|
||||
const accentColor = useCssVar('--theme-color-chart', null, { observe: true });
|
||||
const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
|
||||
|
||||
const seriesData = computed(() => {
|
||||
return props?.groupedData?.map((el) => {
|
||||
@@ -113,7 +111,7 @@ const option = computed(() => ({
|
||||
data: xAxisLabels.value,
|
||||
markLine: {
|
||||
lineStyle: {
|
||||
color: markLineColor.value,
|
||||
color: 'rgba(125,156,188,0.1)',
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
@@ -137,13 +135,9 @@ const option = computed(() => ({
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
color: labelColor.value,
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: splitLineColor.value,
|
||||
color: 'rgba(125,156,188,0.2)', // Set desired color here
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
TooltipComponent,
|
||||
} from 'echarts/components';
|
||||
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
|
||||
import { useCssVariable } from '@/utils/useCssVariable';
|
||||
import { useCssVar } from '@vueuse/core';
|
||||
import type { Organization } from '@/packages/api/src';
|
||||
|
||||
use([
|
||||
@@ -36,7 +36,7 @@ type ReportingChartDataEntry = {
|
||||
const props = defineProps<{
|
||||
data: ReportingChartDataEntry | null;
|
||||
}>();
|
||||
const labelColor = useCssVariable('--color-text-secondary');
|
||||
const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
|
||||
|
||||
const seriesData = computed(() => {
|
||||
return props.data?.map((el) => {
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
formatHumanReadableDuration,
|
||||
getDayJsInstance,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
import { useCssVariable } from '@/utils/useCssVariable';
|
||||
import { useCssVar } from '@vueuse/core';
|
||||
import { useQuery } from '@tanstack/vue-query';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { api, type Organization } from '@/packages/api/src';
|
||||
@@ -64,9 +64,12 @@ const max = computed(() => {
|
||||
}
|
||||
});
|
||||
|
||||
const backgroundColor = useCssVariable('--theme-color-card-background');
|
||||
const itemBackgroundColor = useCssVariable('--color-bg-tertiary');
|
||||
const borderColor = useCssVariable('--color-border');
|
||||
const backgroundColor = useCssVar('--color-card-background', null, {
|
||||
observe: true,
|
||||
});
|
||||
const itemBackgroundColor = useCssVar('--color-bg-tertiary', null, {
|
||||
observe: true,
|
||||
});
|
||||
|
||||
const option = computed(() => {
|
||||
return {
|
||||
@@ -117,7 +120,7 @@ const option = computed(() => {
|
||||
[],
|
||||
itemStyle: {
|
||||
borderRadius: 5,
|
||||
borderColor: borderColor.value,
|
||||
borderColor: 'rgba(255,255,255,0.05)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
tooltip: {
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import VChart from 'vue-echarts';
|
||||
import { computed } from 'vue';
|
||||
import { useCssVariable } from '@/utils/useCssVariable';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useCssVar } from '@vueuse/core';
|
||||
|
||||
const props = defineProps<{
|
||||
history: number[];
|
||||
}>();
|
||||
|
||||
const accentColor = useCssVariable('--theme-color-chart');
|
||||
const markLineColor = useCssVariable('--color-border-secondary');
|
||||
const accentColor = useCssVar('--theme-color-chart', null, { observe: true });
|
||||
|
||||
const seriesData = computed(() => props.history.map((el) => {
|
||||
return {
|
||||
@@ -23,7 +22,7 @@ const seriesData = computed(() => props.history.map((el) => {
|
||||
},
|
||||
};
|
||||
}));
|
||||
const option = computed(() => ({
|
||||
const option = ref({
|
||||
grid: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
@@ -36,7 +35,7 @@ const option = computed(() => ({
|
||||
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
|
||||
markLine: {
|
||||
lineStyle: {
|
||||
color: markLineColor.value,
|
||||
color: 'rgba(125,156,188,0.1)',
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
@@ -67,11 +66,11 @@ const option = computed(() => ({
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: seriesData.value,
|
||||
data: seriesData,
|
||||
type: 'bar',
|
||||
},
|
||||
],
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
TooltipComponent,
|
||||
} from 'echarts/components';
|
||||
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
|
||||
import { useCssVariable } from '@/utils/useCssVariable';
|
||||
import { useCssVar } from "@vueuse/core";
|
||||
import type { Organization } from "@/packages/api/src";
|
||||
|
||||
use([
|
||||
@@ -24,7 +24,7 @@ use([
|
||||
]);
|
||||
|
||||
provide(THEME_KEY, 'dark');
|
||||
const labelColor = useCssVariable('--color-text-secondary');
|
||||
const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
|
||||
|
||||
const props = defineProps<{
|
||||
weeklyProjectOverview: {
|
||||
|
||||
@@ -18,7 +18,7 @@ import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue';
|
||||
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
|
||||
import { formatCents } from '@/packages/ui/src/utils/money';
|
||||
import { getWeekStart } from '@/packages/ui/src/utils/settings';
|
||||
import { useCssVariable } from '@/utils/useCssVariable';
|
||||
import { useCssVar } from '@vueuse/core';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import { useQuery } from '@tanstack/vue-query';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
@@ -60,7 +60,7 @@ const weekdays = computed(() => {
|
||||
}
|
||||
});
|
||||
|
||||
const accentColor = useCssVariable('--theme-color-chart');
|
||||
const accentColor = useCssVar('--theme-color-chart', null, { observe: true });
|
||||
|
||||
// Get the organization ID using the utility function
|
||||
const organizationId = computed(() => getCurrentOrganizationId());
|
||||
@@ -176,8 +176,10 @@ const seriesData = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const markLineColor = useCssVariable('--color-border-secondary');
|
||||
const labelColor = useCssVariable('--color-text-secondary');
|
||||
const markLineColor = useCssVar('--color-border-secondary', null, {
|
||||
observe: true,
|
||||
});
|
||||
const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
|
||||
const option = computed(() => {
|
||||
return {
|
||||
tooltip: {
|
||||
@@ -213,10 +215,6 @@ const option = computed(() => {
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
color: labelColor.value,
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: markLineColor.value,
|
||||
|
||||
@@ -3,6 +3,13 @@ import { computed, watch } from "vue";
|
||||
|
||||
type themeOption = "system" | "light" | "dark";
|
||||
const themeSetting = useStorage<themeOption>("theme", "system");
|
||||
// reload page when themeSettingChanges
|
||||
watch(
|
||||
themeSetting,
|
||||
() => {
|
||||
location.reload();
|
||||
}
|
||||
)
|
||||
const preferredColor = usePreferredColorScheme();
|
||||
const theme = computed(() => {
|
||||
if(themeSetting.value === "system"){
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export function useCssVariable(variableName: string) {
|
||||
const value = ref('')
|
||||
let observer: MutationObserver | null = null
|
||||
let mediaQuery: MediaQueryList | null = null
|
||||
|
||||
const updateValue = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const cssValue = computedStyle.getPropertyValue(variableName).trim()
|
||||
value.value = cssValue
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Initialize with current value
|
||||
updateValue()
|
||||
|
||||
// Watch for class changes on document.documentElement (where theme classes are applied)
|
||||
observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
|
||||
updateValue()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
})
|
||||
|
||||
// Also watch for system color scheme changes
|
||||
if (window.matchMedia) {
|
||||
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
mediaQuery.addEventListener('change', updateValue)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
}
|
||||
if (mediaQuery) {
|
||||
mediaQuery.removeEventListener('change', updateValue)
|
||||
}
|
||||
})
|
||||
|
||||
return value
|
||||
}
|
||||
@@ -129,31 +129,26 @@ class InvitationEndpointTest extends ApiEndpointTestAbstract
|
||||
$response->assertJsonPath('message', 'User is already a member of the organization');
|
||||
}
|
||||
|
||||
public function test_store_fails_if_an_invitation_with_the_same_email_already_exists(): void
|
||||
public function test_store_fails_if_user_invites_user_who_is_already_invited_to_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'invitations:create',
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
$email = 'user@email.test';
|
||||
$invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create([
|
||||
'email' => $email,
|
||||
]);
|
||||
$invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create();
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.invitations.store', $data->organization->getKey()), [
|
||||
'email' => $email,
|
||||
'email' => $invitation->email,
|
||||
'role' => Role::Employee->value,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(400);
|
||||
$response->assertExactJson([
|
||||
'error' => true,
|
||||
'key' => 'invitation_for_the_email_already_exists',
|
||||
'message' => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
|
||||
$response->assertInvalid([
|
||||
'email' => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
|
||||
]);
|
||||
$response->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_store_works_if_user_invites_user_who_is_also_a_placeholder(): void
|
||||
|
||||
@@ -10,7 +10,6 @@ use App\Events\MemberRemoved;
|
||||
use App\Http\Controllers\Api\V1\MemberController;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\TimeEntry;
|
||||
@@ -654,103 +653,6 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
Event::assertNotDispatched(MemberRemoved::class);
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_also_deletes_user_if_member_is_placeholder(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'members:delete',
|
||||
]);
|
||||
$user = User::factory()->placeholder()->create();
|
||||
$member = Member::factory()->forUser($user)->forOrganization($data->organization)->role(Role::Placeholder)->create();
|
||||
Passport::actingAs($data->user);
|
||||
Event::fake([
|
||||
MemberRemoved::class,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $member->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(204);
|
||||
$this->assertDatabaseMissing(Member::class, [
|
||||
'id' => $member->getKey(),
|
||||
]);
|
||||
$this->assertDatabaseMissing(User::class, [
|
||||
'id' => $user->getKey(),
|
||||
]);
|
||||
Event::assertDispatched(function (MemberRemoved $event) use ($data, $member): bool {
|
||||
return $event->organization->is($data->organization) &&
|
||||
$event->member->is($member);
|
||||
}, 1);
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_sets_current_organization_to_organization_the_user_is_still_member_of(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'members:delete',
|
||||
]);
|
||||
$user = $data->user;
|
||||
$otherOrganization = Organization::factory()->create();
|
||||
$otherMember = Member::factory()->forOrganization($otherOrganization)->forUser($user)->role(Role::Employee)->create();
|
||||
Passport::actingAs($user);
|
||||
Event::fake([
|
||||
MemberRemoved::class,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $data->member->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(204);
|
||||
$this->assertDatabaseMissing(Member::class, [
|
||||
'id' => $data->member->getKey(),
|
||||
]);
|
||||
$user->refresh();
|
||||
$this->assertSame($otherOrganization->getKey(), $user->currentOrganization->getKey());
|
||||
Event::assertDispatched(function (MemberRemoved $event) use ($data): bool {
|
||||
return $event->organization->is($data->organization) &&
|
||||
$event->member->is($data->member);
|
||||
}, 1);
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_creates_new_organization_and_sets_the_current_organization_to_it_if_user_is_not_member_of_any_other_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'members:delete',
|
||||
]);
|
||||
$organization = $data->organization;
|
||||
$user = $data->user;
|
||||
Passport::actingAs($user);
|
||||
Event::fake([
|
||||
MemberRemoved::class,
|
||||
]);
|
||||
$this->assertDatabaseCount(Organization::class, 1);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $data->member->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(204);
|
||||
$this->assertDatabaseCount(Organization::class, 2);
|
||||
$newOrganization = Organization::where('id', '!=', $organization->getKey())->first();
|
||||
$this->assertNotNull($newOrganization);
|
||||
$this->assertDatabaseMissing(Member::class, [
|
||||
'id' => $data->member->getKey(),
|
||||
]);
|
||||
$this->assertDatabaseHas(Member::class, [
|
||||
'organization_id' => $newOrganization->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
]);
|
||||
$user->refresh();
|
||||
$this->assertNotNull($user->currentOrganization);
|
||||
Event::assertDispatched(function (MemberRemoved $event) use ($data): bool {
|
||||
return $event->organization->is($data->organization) &&
|
||||
$event->member->is($data->member);
|
||||
}, 1);
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_succeeds_if_member_is_still_in_use_by_a_project_member_and_delete_related_is_active(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -1035,37 +937,6 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_invite_placeholder_fails_if_there_is_already_an_invitation_with_the_same_email(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'members:invite-placeholder',
|
||||
'invitations:create',
|
||||
]);
|
||||
$placeholder = User::factory()->placeholder()->create([
|
||||
'email' => 'user@mail.test',
|
||||
]);
|
||||
$placeholderMember = Member::factory()->forUser($placeholder)->forOrganization($data->organization)->role(Role::Placeholder)->create();
|
||||
OrganizationInvitation::factory()->forOrganization($data->organization)->create([
|
||||
'email' => $placeholder->email,
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.members.invite-placeholder', [
|
||||
'organization' => $data->organization->id,
|
||||
'member' => $placeholderMember->id,
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(400);
|
||||
$response->assertExactJson([
|
||||
'error' => true,
|
||||
'key' => 'invitation_for_the_email_already_exists',
|
||||
'message' => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_invite_placeholder_returns_400_if_user_is_not_placeholder(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
Reference in New Issue
Block a user