mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Compare commits
118 Commits
v0.1.0
...
feature/ta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b84ba67cd | ||
|
|
8b50f33cc9 | ||
|
|
014bffe86d | ||
|
|
2dbde63043 | ||
|
|
876a41cb2a | ||
|
|
1036502e49 | ||
|
|
5bf4dc79c2 | ||
|
|
2592dd3b9e | ||
|
|
05f240efc9 | ||
|
|
d5b35ef420 | ||
|
|
7e5374d5b1 | ||
|
|
36cdae523f | ||
|
|
b2ad4b3785 | ||
|
|
5e4270e3f5 | ||
|
|
d4e71e7c2c | ||
|
|
5c6b32d5bb | ||
|
|
37400d239c | ||
|
|
50902e7705 | ||
|
|
498f29617e | ||
|
|
61cc80dc6e | ||
|
|
0a0b7a03b4 | ||
|
|
cc10af0b97 | ||
|
|
d3545b3c73 | ||
|
|
9e1413c15f | ||
|
|
ac85e778a4 | ||
|
|
9189910136 | ||
|
|
85315fc62f | ||
|
|
91b56ae92f | ||
|
|
845f0d19d8 | ||
|
|
d211e962f5 | ||
|
|
f0705e1e4a | ||
|
|
b990387775 | ||
|
|
a4d6ba3cdb | ||
|
|
3b41d90b07 | ||
|
|
b391f47d1b | ||
|
|
19cc05140a | ||
|
|
5592d87cd5 | ||
|
|
b518187ecb | ||
|
|
c09119af33 | ||
|
|
ceba49d054 | ||
|
|
01dd13b947 | ||
|
|
83301d03ca | ||
|
|
4969fcba7e | ||
|
|
48b2bb436e | ||
|
|
30ed47d3fb | ||
|
|
2bad9eaa3c | ||
|
|
78b41ea0b7 | ||
|
|
d8968399d6 | ||
|
|
5b7df869ad | ||
|
|
7c593f8f87 | ||
|
|
22b2933d85 | ||
|
|
6dd9d5bab0 | ||
|
|
9a8945b0dc | ||
|
|
fc614b796c | ||
|
|
b031598f79 | ||
|
|
07823291ae | ||
|
|
75012ea020 | ||
|
|
49de8d0900 | ||
|
|
156d2ff1a0 | ||
|
|
a01e1d6b0b | ||
|
|
9df91f4e4a | ||
|
|
e538fec7c7 | ||
|
|
aee5ea456e | ||
|
|
2c0ab5e15a | ||
|
|
0245eccaeb | ||
|
|
ee77de04ef | ||
|
|
056a63e193 | ||
|
|
024d841024 | ||
|
|
597f9ce802 | ||
|
|
18ac9acc2a | ||
|
|
f6d9dfa6bb | ||
|
|
64d422f5f7 | ||
|
|
b3b8b9fba9 | ||
|
|
e981d6bc01 | ||
|
|
859833452f | ||
|
|
33d139e3aa | ||
|
|
0c05ad240d | ||
|
|
4ad68b4f4e | ||
|
|
249b1b5820 | ||
|
|
1328692faf | ||
|
|
35c65d3bf0 | ||
|
|
c3cad88949 | ||
|
|
f4d4ea8b98 | ||
|
|
05ece9b0ee | ||
|
|
571054b816 | ||
|
|
f014137623 | ||
|
|
b2d327e8b1 | ||
|
|
c6ee2b5131 | ||
|
|
b689784701 | ||
|
|
b375cba5f7 | ||
|
|
635954f81d | ||
|
|
b7c9aa6f28 | ||
|
|
87b114a32a | ||
|
|
00e095ec4b | ||
|
|
b741105cfa | ||
|
|
16203ec748 | ||
|
|
06a35cb447 | ||
|
|
7c1b828ad3 | ||
|
|
ea90b0acb2 | ||
|
|
10cc5cf42a | ||
|
|
04bb8e50a7 | ||
|
|
6aef8856f5 | ||
|
|
06fef6e40f | ||
|
|
a9c874e540 | ||
|
|
21207a4058 | ||
|
|
0e7dec2f40 | ||
|
|
99c652a61b | ||
|
|
1e4f0afa67 | ||
|
|
655723db49 | ||
|
|
10d8540e6c | ||
|
|
cbdbcef9eb | ||
|
|
a519c119d4 | ||
|
|
375cee7589 | ||
|
|
ba07616111 | ||
|
|
63323d86c3 | ||
|
|
8db0a7d25e | ||
|
|
855db81104 | ||
|
|
055d93f7a3 |
19
.env.example
19
.env.example
@@ -3,6 +3,7 @@ APP_ENV=local
|
||||
APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
|
||||
APP_DEBUG=true
|
||||
APP_URL=https://solidtime.test
|
||||
AUDITING_ENABLED=true
|
||||
|
||||
SUPER_ADMINS=admin@example.com
|
||||
|
||||
@@ -26,7 +27,6 @@ DB_TEST_PASSWORD=root
|
||||
|
||||
BROADCAST_DRIVER=log
|
||||
CACHE_DRIVER=file
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=sync
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
@@ -46,12 +46,6 @@ MAIL_ENCRYPTION=null
|
||||
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_REGION=us-east-1
|
||||
S3_BUCKET=
|
||||
S3_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
PUSHER_APP_ID=
|
||||
PUSHER_APP_KEY=
|
||||
PUSHER_APP_SECRET=
|
||||
@@ -60,6 +54,17 @@ PUSHER_PORT=443
|
||||
PUSHER_SCHEME=https
|
||||
PUSHER_APP_CLUSTER=mt1
|
||||
|
||||
# Storage
|
||||
FILESYSTEM_DISK=s3
|
||||
PUBLIC_FILESYSTEM_DISK=s3
|
||||
S3_ACCESS_KEY_ID=sail
|
||||
S3_SECRET_ACCESS_KEY=password
|
||||
S3_REGION=us-east-1
|
||||
S3_BUCKET=local
|
||||
S3_URL=http://storage.solidtime.test/local
|
||||
S3_ENDPOINT=http://storage.solidtime.test
|
||||
S3_USE_PATH_STYLE_ENDPOINT=true
|
||||
|
||||
VITE_HOST_NAME=vite.solidtime.test
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
|
||||
|
||||
6
.github/workflows/build-private.yml
vendored
6
.github/workflows/build-private.yml
vendored
@@ -15,7 +15,7 @@ name: Build - Private
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: "Check out code"
|
||||
@@ -114,6 +114,9 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,format=long
|
||||
|
||||
- name: "Set up QEMU"
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: "Set up Docker Buildx"
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -125,6 +128,7 @@ jobs:
|
||||
DOCKER_FILES_BASE_PATH=docker/prod/
|
||||
file: docker/prod/Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
|
||||
90
.github/workflows/build-public-release.yml
vendored
Normal file
90
.github/workflows/build-public-release.yml
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build-public.yml'
|
||||
- 'docker/prod/**'
|
||||
workflow_dispatch:
|
||||
|
||||
name: Build - Public (Release)
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
attestations: write
|
||||
id-token: write
|
||||
timeout-minutes: 90
|
||||
|
||||
steps:
|
||||
- name: "Check out code"
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: "Copy .env template for production"
|
||||
run: cp .env.production .env
|
||||
|
||||
- name: "Install dependencies"
|
||||
uses: php-actions/composer@v6
|
||||
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
||||
with:
|
||||
command: install
|
||||
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
||||
php_version: 8.3
|
||||
|
||||
- name: "Use Node.js"
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: "Install npm dependencies"
|
||||
run: npm ci
|
||||
|
||||
- name: "Build"
|
||||
run: npm run build
|
||||
|
||||
- name: "Login to GitHub Container Registry"
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: "Login to GitHub Container Registry"
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: "Docker meta"
|
||||
id: "meta"
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
solidtime/solidtime
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: "Set up QEMU"
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: "Set up Docker Buildx"
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: "Build and push"
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/prod/Dockerfile
|
||||
build-args: |
|
||||
DOCKER_FILES_BASE_PATH=docker/prod/
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
25
.github/workflows/build-public.yml
vendored
25
.github/workflows/build-public.yml
vendored
@@ -3,8 +3,6 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
tags:
|
||||
- '*'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build-public.yml'
|
||||
@@ -15,7 +13,12 @@ name: Build - Public
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
attestations: write
|
||||
id-token: write
|
||||
timeout-minutes: 90
|
||||
|
||||
steps:
|
||||
- name: "Check out code"
|
||||
@@ -48,18 +51,28 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: "Login to GitHub Container Registry"
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: "Docker meta"
|
||||
id: "meta"
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: solidtime/solidtime
|
||||
images: |
|
||||
solidtime/solidtime
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: "Set up QEMU"
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: "Set up Docker Buildx"
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -70,7 +83,7 @@ jobs:
|
||||
file: docker/prod/Dockerfile
|
||||
build-args: |
|
||||
DOCKER_FILES_BASE_PATH=docker/prod/
|
||||
platforms: linux/amd64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
29
.github/workflows/npm-publish-api.yml
vendored
Normal file
29
.github/workflows/npm-publish-api.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: Publish API package to NPM
|
||||
on:
|
||||
workflow_dispatch
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Setup .npmrc file to publish to npm
|
||||
- name: Install root project dependencies
|
||||
run: npm ci
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: ./resources/js/packages/api
|
||||
- name: Build package
|
||||
run: npm run build
|
||||
working-directory: ./resources/js/packages/api
|
||||
- name: Publish Package
|
||||
run: npm publish --provenance --access public
|
||||
working-directory: ./resources/js/packages/api
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
29
.github/workflows/npm-publish-ui.yml
vendored
Normal file
29
.github/workflows/npm-publish-ui.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: Publish UI package to NPM
|
||||
on:
|
||||
workflow_dispatch
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- name: Install root project dependencies
|
||||
run: npm ci
|
||||
- name: Install package dependencies
|
||||
run: npm ci
|
||||
working-directory: ./resources/js/packages/ui
|
||||
- name: Build package
|
||||
run: npm run build
|
||||
working-directory: ./resources/js/packages/ui
|
||||
- name: Publish Package
|
||||
run: npm publish --provenance --access public
|
||||
working-directory: ./resources/js/packages/ui
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
/.phpunit.cache
|
||||
/node_modules
|
||||
node_modules
|
||||
dist
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
|
||||
77
README.md
77
README.md
@@ -20,82 +20,13 @@ solidtime is a modern open-source time tracking application for Freelancers and
|
||||
- Roles and permissions: Create and manage organizations
|
||||
- Import: Import your time tracking data from other time tracking applications (Supported: Toggl, Clockify, Timeentry CSV)
|
||||
|
||||
## Local setup for development
|
||||
## Self Hosting
|
||||
|
||||
**System requirements**
|
||||
* Docker
|
||||
If you are looking into self-hosting solidtime, you can find the guides [here](https://docs.solidtime.io/self-hosting/intro)
|
||||
|
||||
First you need to download or clone the repository f.e. with `git@github.com:solidtime-io/solidtime.git`.
|
||||
We also have an examples repository [here](https://github.com/solidtime-io/self-hosting-examples)
|
||||
|
||||
After that, execute the following commands **inside the project folder**:
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
--pull=always \
|
||||
-v "$(pwd)":/opt \
|
||||
-w /opt \
|
||||
laravelsail/php83-composer:latest \
|
||||
bash -c "composer install --ignore-platform-reqs"
|
||||
|
||||
cp .env.example .env
|
||||
|
||||
./vendor/bin/sail up -d
|
||||
|
||||
./vendor/bin/sail artisan key:generate
|
||||
|
||||
./vendor/bin/sail artisan migrate:fresh --seed
|
||||
|
||||
./vendor/bin/sail php artisan passport:install
|
||||
|
||||
./vendor/bin/sail npm install
|
||||
|
||||
./vendor/bin/sail npm run build
|
||||
```
|
||||
|
||||
Make sure to set the APP_PORT and VITE_PORT inside your `.env` file to a port that is not already used by your system.
|
||||
|
||||
By default the application will run on [localhost:8083](http://localhost:8083/)
|
||||
|
||||
### Setup with Reverse Proxy
|
||||
|
||||
**Additional System Requirements**
|
||||
* Traefik 2 Reverse-Proxy (https://github.com/korridor/reverse-proxy-docker-traefik)
|
||||
|
||||
Add the following entry to your `/etc/hosts`
|
||||
|
||||
```
|
||||
127.0.0.1 solidtime.test
|
||||
127.0.0.1 playwright.solidtime.test
|
||||
127.0.0.1 vite.solidtime.test
|
||||
127.0.0.1 mail.solidtime.test
|
||||
```
|
||||
|
||||
### Running E2E Tests
|
||||
|
||||
`./vendor/bin/sail up -d ` will automatically start a Playwright UI server that you can access at `https://playwright.solidtime.test`.
|
||||
Make sure that you use HTTPS otherwise the resources will not be loaded correctly.
|
||||
|
||||
### Recording E2E Tests
|
||||
|
||||
To record E2E tests, you need to install and execute playwright locally (outside the Docker container) using:
|
||||
|
||||
```bash
|
||||
npx playwright install
|
||||
npx playwright codegen solidtime.test
|
||||
```
|
||||
|
||||
### E2E Troubleshooting
|
||||
|
||||
If E2E tests are not working at all, make sure you do not have the Vite server running and just run `npm run build` to update the version.
|
||||
If the E2E tests are not working consistently and fail with a timeout during the authentication, you might want to delete the `test-results/.auth` directory to force new test accounts to be created.
|
||||
|
||||
### Generate ZOD Client
|
||||
|
||||
The Zodius HTTP client is generated using the following command:
|
||||
|
||||
```bash
|
||||
npm run zod:generate
|
||||
```
|
||||
If you do not want to self-host solidtime or try it out you can sign up for [solidtime cloud](https://www.solidtime.io/)
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -62,7 +62,10 @@ class CreateNewUser implements CreatesNewUsers
|
||||
if (app(TimezoneService::class)->isValid($input['timezone'])) {
|
||||
$timezone = $input['timezone'];
|
||||
} else {
|
||||
Log::debug('Invalid timezone', ['timezone' => $input['timezone']]);
|
||||
$timezone = app(TimezoneService::class)->mapLegacyTimezone($input['timezone']);
|
||||
if ($timezone === null) {
|
||||
Log::debug('Invalid timezone', ['timezone' => $input['timezone']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,30 +80,31 @@ class CreateNewUser implements CreatesNewUsers
|
||||
}
|
||||
$currency = $ipLookupResponse->currency;
|
||||
}
|
||||
|
||||
$user = DB::transaction(function () use ($input, $timezone, $startOfWeek, $currency) {
|
||||
return tap(User::create([
|
||||
$user = null;
|
||||
$organization = null;
|
||||
DB::transaction(function () use (&$user, &$organization, $input, $timezone, $startOfWeek, $currency) {
|
||||
$user = User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make($input['password']),
|
||||
'timezone' => $timezone ?? 'UTC',
|
||||
'week_start' => $startOfWeek,
|
||||
]), function (User $user) use ($currency): void {
|
||||
$organization = new Organization();
|
||||
$organization->name = explode(' ', $user->name, 2)[0]."'s Organization";
|
||||
$organization->personal_team = true;
|
||||
$organization->currency = $currency ?? 'EUR';
|
||||
$organization->owner()->associate($user);
|
||||
$organization->save();
|
||||
]);
|
||||
|
||||
$organization->users()->attach(
|
||||
$user, [
|
||||
'role' => Role::Owner->value,
|
||||
]
|
||||
);
|
||||
$organization = new Organization;
|
||||
$organization->name = explode(' ', $user->name, 2)[0]."'s Organization";
|
||||
$organization->personal_team = true;
|
||||
$organization->currency = $currency ?? 'EUR';
|
||||
$organization->owner()->associate($user);
|
||||
$organization->save();
|
||||
|
||||
$user->ownedTeams()->save($organization);
|
||||
});
|
||||
$organization->users()->attach(
|
||||
$user, [
|
||||
'role' => Role::Owner->value,
|
||||
]
|
||||
);
|
||||
|
||||
$user->ownedTeams()->save($organization);
|
||||
});
|
||||
|
||||
$newsletterConsent = isset($input['newsletter_consent']) && (bool) $input['newsletter_consent'];
|
||||
|
||||
@@ -72,6 +72,7 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
'required',
|
||||
'email',
|
||||
(new ExistsEloquent(User::class, 'email', function (Builder $builder) {
|
||||
/** @var Builder<User> $builder */
|
||||
return $builder->where('is_placeholder', '=', false);
|
||||
}))->withMessage(__('We were unable to find a registered user with this email address.')),
|
||||
],
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Events\AfterCreateOrganization;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
@@ -12,7 +13,6 @@ use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Jetstream\Contracts\CreatesTeams;
|
||||
use Laravel\Jetstream\Events\AddingTeam;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
|
||||
class CreateOrganization implements CreatesTeams
|
||||
@@ -33,9 +33,7 @@ class CreateOrganization implements CreatesTeams
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
])->validateWithBag('createTeam');
|
||||
|
||||
AddingTeam::dispatch($user);
|
||||
|
||||
$organization = new Organization();
|
||||
$organization = new Organization;
|
||||
$organization->name = $input['name'];
|
||||
$organization->personal_team = false;
|
||||
$organization->owner()->associate($user);
|
||||
@@ -47,10 +45,12 @@ class CreateOrganization implements CreatesTeams
|
||||
]
|
||||
);
|
||||
|
||||
$user->ownedTeams()->save($organization);
|
||||
|
||||
$user->switchTeam($organization);
|
||||
|
||||
// Note: The refresh is necessary for currently unknown reasons. Do not remove it.
|
||||
$organization = $organization->refresh();
|
||||
AfterCreateOrganization::dispatch($organization);
|
||||
|
||||
return $organization;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ class InviteOrganizationMember implements InvitesTeamMembers
|
||||
*/
|
||||
public function invite(User $user, Organization $organization, string $email, ?string $role = null): void
|
||||
{
|
||||
throw new MovedToApiException();
|
||||
throw new MovedToApiException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ class RemoveOrganizationMember implements RemovesTeamMembers
|
||||
*/
|
||||
public function remove(User $user, Organization $organization, User $teamMember): void
|
||||
{
|
||||
throw new MovedToApiException();
|
||||
throw new MovedToApiException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,6 @@ class UpdateMemberRole
|
||||
*/
|
||||
public function update(User $actingUser, Organization $organization, string $userId, string $role): void
|
||||
{
|
||||
throw new MovedToApiException();
|
||||
throw new MovedToApiException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ class UpdateOrganization implements UpdatesTeamNames
|
||||
'currency' => [
|
||||
'required',
|
||||
'string',
|
||||
new CurrencyRule(),
|
||||
new CurrencyRule,
|
||||
],
|
||||
])->validateWithBag('updateTeamName');
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ class ValidateOrganizationDeletion
|
||||
public function validate(User $user, Organization $organization): void
|
||||
{
|
||||
if (! app(PermissionStore::class)->userHas($organization, $user, 'organizations:delete')) {
|
||||
throw new AuthorizationException();
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,14 @@ use App\Service\DeletionService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DeleteOrganizationCommand extends Command
|
||||
class OrganizationDeleteCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'admin:delete-organization
|
||||
protected $signature = 'admin:organization:delete
|
||||
{ organization : The ID of the organization to delete }';
|
||||
|
||||
/**
|
||||
@@ -24,7 +24,7 @@ class DeleteOrganizationCommand extends Command
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Delete a organization.';
|
||||
protected $description = 'Delete a organization';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
59
app/Console/Commands/Admin/UserVerifyCommand.php
Normal file
59
app/Console/Commands/Admin/UserVerifyCommand.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Admin;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class UserVerifyCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'admin:user:verify
|
||||
{ email : The email of the user to verify }';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Verify the email address of an user';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$email = $this->argument('email');
|
||||
|
||||
$this->info('Start verifying user with email "'.$email.'"');
|
||||
|
||||
/** @var User|null $user */
|
||||
$user = User::where('email', $email)->first();
|
||||
|
||||
if ($user === null) {
|
||||
$this->error('User with email "'.$email.'" not found.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($user->hasVerifiedEmail()) {
|
||||
$this->info('User with email "'.$email.'" already verified.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$user->markEmailAsVerified();
|
||||
event(new Verified($user));
|
||||
|
||||
$this->info('User with email "'.$email.'" has been verified.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\TimeEntry;
|
||||
|
||||
use App\Mail\TimeEntryStillRunningMail;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class TimeEntrySendStillRunningMailsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'time-entry:send-still-running-mails '.
|
||||
' { --dry-run : Do not actually send emails or save anything to the database, just output what would happen }';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Sends emails to users who have running time entries for more than 8 hours.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$this->comment('Sending still running time entry emails...');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
if ($dryRun) {
|
||||
$this->comment('Running in dry-run mode. No emails will be sent and nothing will be saved to the database.');
|
||||
}
|
||||
|
||||
$sentMails = 0;
|
||||
TimeEntry::query()
|
||||
->whereNull('end')
|
||||
->where('start', '<', now()->subHours(8))
|
||||
->whereNull('still_active_email_sent_at')
|
||||
->with([
|
||||
'user',
|
||||
])
|
||||
->whereHas('user', function (Builder $query): void {
|
||||
/** @var Builder<User> $query */
|
||||
$query->where('is_placeholder', '=', false);
|
||||
})
|
||||
->orderBy('created_at', 'asc')
|
||||
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails) {
|
||||
/** @var Collection<int, TimeEntry> $timeEntries */
|
||||
foreach ($timeEntries as $timeEntry) {
|
||||
$user = $timeEntry->user;
|
||||
$this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') for time entry '.$timeEntry->getKey());
|
||||
$sentMails++;
|
||||
if (! $dryRun) {
|
||||
Mail::to($user->email)
|
||||
->queue(new TimeEntryStillRunningMail($timeEntry, $user));
|
||||
$timeEntry->still_active_email_sent_at = Carbon::now();
|
||||
$timeEntry->save();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->comment('Finished sending '.$sentMails.' still running time entry emails...');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,9 @@ class Kernel extends ConsoleKernel
|
||||
*/
|
||||
protected function schedule(Schedule $schedule): void
|
||||
{
|
||||
// $schedule->command('inspire')->hourly();
|
||||
$schedule->command('time-entry:send-still-running-mails')
|
||||
->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails'))
|
||||
->everyTenMinutes();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,7 @@ enum TimeEntryAggregationType: string
|
||||
case Task = 'task';
|
||||
case Client = 'client';
|
||||
case Billable = 'billable';
|
||||
case Description = 'description';
|
||||
|
||||
public function toInterval(): ?TimeEntryAggregationTypeInterval
|
||||
{
|
||||
|
||||
26
app/Events/AfterCreateOrganization.php
Normal file
26
app/Events/AfterCreateOrganization.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* This event is fired after an organization has been created.
|
||||
* This event does NOT fire when an organization is created as part of a registration.
|
||||
*/
|
||||
class AfterCreateOrganization
|
||||
{
|
||||
use Dispatchable;
|
||||
use SerializesModels;
|
||||
|
||||
public Organization $organization;
|
||||
|
||||
public function __construct(Organization $organization)
|
||||
{
|
||||
$this->organization = $organization;
|
||||
}
|
||||
}
|
||||
24
app/Events/MemberMadeToPlaceholder.php
Normal file
24
app/Events/MemberMadeToPlaceholder.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
class MemberMadeToPlaceholder
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public Organization $organization;
|
||||
|
||||
public Member $member;
|
||||
|
||||
public function __construct(Member $member, Organization $organization)
|
||||
{
|
||||
$this->member = $member;
|
||||
$this->organization = $organization;
|
||||
}
|
||||
}
|
||||
24
app/Events/MemberRemoved.php
Normal file
24
app/Events/MemberRemoved.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
class MemberRemoved
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public Organization $organization;
|
||||
|
||||
public Member $member;
|
||||
|
||||
public function __construct(Member $member, Organization $organization)
|
||||
{
|
||||
$this->member = $member;
|
||||
$this->organization = $organization;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class OrganizationHasNoSubscriptionButMultipleMembersException extends ApiException
|
||||
{
|
||||
public const string KEY = 'organization_has_no_subscription_but_multiple_members';
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Extensions\Auditing\Resolvers;
|
||||
|
||||
use Illuminate\Support\Facades\Request;
|
||||
use OwenIt\Auditing\Contracts\Auditable;
|
||||
use OwenIt\Auditing\Contracts\Resolver;
|
||||
|
||||
class CustomIpAddressResolver implements Resolver
|
||||
{
|
||||
private static function anonymizeIpAddress(string $ipAddress): string
|
||||
{
|
||||
/** @source https://stackoverflow.com/a/48777412 */
|
||||
return preg_replace(
|
||||
['/\.\d*$/', '/[\da-f]*:[\da-f]*$/'],
|
||||
['.0', '0:0'],
|
||||
$ipAddress
|
||||
);
|
||||
}
|
||||
|
||||
public static function resolve(Auditable $auditable): string
|
||||
{
|
||||
$ip = $auditable->preloadedResolverData['ip_address'] ?? Request::ip();
|
||||
|
||||
if ($ip !== null) {
|
||||
$ip = self::anonymizeIpAddress($ip);
|
||||
}
|
||||
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
@@ -24,20 +24,20 @@ class ApiExceptionTypeToSchema extends ExceptionToResponseExtension
|
||||
|
||||
public function toResponse(Type $type): Response
|
||||
{
|
||||
$validationResponseBodyType = (new OpenApiTypes\ObjectType())
|
||||
$validationResponseBodyType = (new OpenApiTypes\ObjectType)
|
||||
->addProperty(
|
||||
'error',
|
||||
(new OpenApiTypes\BooleanType())
|
||||
(new OpenApiTypes\BooleanType)
|
||||
->setDescription('Whether the response is an error.')
|
||||
)
|
||||
->addProperty(
|
||||
'key',
|
||||
(new OpenApiTypes\StringType())
|
||||
(new OpenApiTypes\StringType)
|
||||
->setDescription('Error key.')
|
||||
)
|
||||
->addProperty(
|
||||
'message',
|
||||
(new OpenApiTypes\StringType())
|
||||
(new OpenApiTypes\StringType)
|
||||
->setDescription('Error message.')
|
||||
)
|
||||
->setRequired(['error', 'key', 'message']);
|
||||
|
||||
@@ -27,13 +27,10 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
|
||||
&& $type->isInstanceOf(PaginatedResourceCollection::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Generic $type
|
||||
*/
|
||||
public function toResponse(Type $type): ?Response
|
||||
public function toSchema(Type $type): ?OpenApiObjectType
|
||||
{
|
||||
/** @var Type|null $collectingClassType */
|
||||
$collectingClassType = $type->templateTypes[0];
|
||||
$collectingClassType = $type->templateTypes[0] ?? null;
|
||||
|
||||
if (! $collectingClassType instanceof ObjectType) {
|
||||
return null;
|
||||
@@ -48,7 +45,7 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
|
||||
}
|
||||
|
||||
$type = new OpenApiObjectType;
|
||||
$type->addProperty('data', (new ArrayType())->setItems($collectingType));
|
||||
$type->addProperty('data', (new ArrayType)->setItems($collectingType));
|
||||
$type->addProperty(
|
||||
'links',
|
||||
(new OpenApiObjectType)
|
||||
@@ -79,6 +76,21 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
|
||||
);
|
||||
$type->setRequired(['data', 'links', 'meta']);
|
||||
|
||||
return $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Generic $type
|
||||
*/
|
||||
public function toResponse(Type $type): ?Response
|
||||
{
|
||||
/** @var ObjectType|null $collectingClassType */
|
||||
$collectingClassType = $type->templateTypes[0] ?? null;
|
||||
if (! $collectingClassType instanceof ObjectType) {
|
||||
return null;
|
||||
}
|
||||
$type = $this->toSchema($type);
|
||||
|
||||
return Response::make(200)
|
||||
->description('Paginated set of `'.$this->components->uniqueSchemaName($collectingClassType->name).'`')
|
||||
->setContent('application/json', Schema::fromType($type));
|
||||
|
||||
95
app/Filament/Resources/AuditResource.php
Normal file
95
app/Filament/Resources/AuditResource.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\AuditResource\Pages;
|
||||
use App\Models\Audit;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Str;
|
||||
use Novadaemon\FilamentPrettyJson\PrettyJson;
|
||||
|
||||
class AuditResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Audit::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-archive-box';
|
||||
|
||||
protected static ?string $navigationGroup = 'System';
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('user_type')
|
||||
->maxLength(255),
|
||||
Forms\Components\TextInput::make('user_id'),
|
||||
Forms\Components\TextInput::make('event')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Forms\Components\TextInput::make('auditable_type')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Forms\Components\TextInput::make('auditable_id')
|
||||
->required(),
|
||||
PrettyJson::make('old_values'),
|
||||
PrettyJson::make('new_values'),
|
||||
Forms\Components\Textarea::make('url'),
|
||||
Forms\Components\TextInput::make('ip_address'),
|
||||
Forms\Components\TextInput::make('user_agent')
|
||||
->maxLength(1023),
|
||||
Forms\Components\TextInput::make('tags')
|
||||
->maxLength(255),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('user.name'),
|
||||
Tables\Columns\TextColumn::make('event'),
|
||||
Tables\Columns\TextColumn::make('auditable_type'),
|
||||
Tables\Columns\TextColumn::make('auditable_id'),
|
||||
IconColumn::make('was_command')
|
||||
->getStateUsing(fn (Audit $record) => Str::startsWith($record->url, 'artisan '))
|
||||
->boolean(),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->sortable()
|
||||
->dateTime(),
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
->sortable()
|
||||
->dateTime(),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\ViewAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
])
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListAudits::route('/'),
|
||||
'create' => Pages\CreateAudit::route('/create'),
|
||||
'view' => Pages\ViewAudit::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
13
app/Filament/Resources/AuditResource/Pages/CreateAudit.php
Normal file
13
app/Filament/Resources/AuditResource/Pages/CreateAudit.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\AuditResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AuditResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateAudit extends CreateRecord
|
||||
{
|
||||
protected static string $resource = AuditResource::class;
|
||||
}
|
||||
18
app/Filament/Resources/AuditResource/Pages/ListAudits.php
Normal file
18
app/Filament/Resources/AuditResource/Pages/ListAudits.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\AuditResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AuditResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListAudits extends ListRecords
|
||||
{
|
||||
protected static string $resource = AuditResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
13
app/Filament/Resources/AuditResource/Pages/ViewAudit.php
Normal file
13
app/Filament/Resources/AuditResource/Pages/ViewAudit.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\AuditResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AuditResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewAudit extends ViewRecord
|
||||
{
|
||||
protected static string $resource = AuditResource::class;
|
||||
}
|
||||
115
app/Filament/Resources/FailedJobResource.php
Normal file
115
app/Filament/Resources/FailedJobResource.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\FailedJobResource\Pages\ListFailedJobs;
|
||||
use App\Filament\Resources\FailedJobResource\Pages\ViewFailedJobs;
|
||||
use App\Models\FailedJob;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Actions\BulkAction;
|
||||
use Filament\Tables\Actions\DeleteAction;
|
||||
use Filament\Tables\Actions\ViewAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Novadaemon\FilamentPrettyJson\PrettyJson;
|
||||
|
||||
/**
|
||||
* @source https://gitlab.com/amvisor/filament-failed-jobs
|
||||
*/
|
||||
class FailedJobResource extends Resource
|
||||
{
|
||||
protected static ?string $model = FailedJob::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-exclamation-circle';
|
||||
|
||||
protected static ?string $navigationGroup = 'System';
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return (string) FailedJob::query()->count();
|
||||
}
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
TextInput::make('uuid')->disabled()->columnSpan(4),
|
||||
TextInput::make('failed_at')->disabled(),
|
||||
TextInput::make('id')->disabled(),
|
||||
TextInput::make('connection')->disabled(),
|
||||
TextInput::make('queue')->disabled(),
|
||||
|
||||
// make text a little bit smaller because often a complete Stack Trace is shown:
|
||||
TextArea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']),
|
||||
PrettyJson::make('payload')->disabled()->columnSpan(4),
|
||||
])->columns(4);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('id', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('id')->sortable()->searchable()->toggleable(),
|
||||
TextColumn::make('failed_at')->sortable()->searchable(false)->toggleable(),
|
||||
TextColumn::make('exception')
|
||||
->sortable()
|
||||
->searchable()
|
||||
->toggleable()
|
||||
->wrap()
|
||||
->limit(200)
|
||||
->tooltip(fn (FailedJob $record) => "{$record->failed_at} UUID: {$record->uuid}; Connection: {$record->connection}; Queue: {$record->queue};"),
|
||||
TextColumn::make('uuid')->sortable()->searchable()->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('connection')->sortable()->searchable()->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('queue')->sortable()->searchable()->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([])
|
||||
->bulkActions([
|
||||
BulkAction::make('retry')
|
||||
->label('Retry')
|
||||
->requiresConfirmation()
|
||||
->action(function (Collection $records): void {
|
||||
/** @var FailedJob $record */
|
||||
foreach ($records as $record) {
|
||||
Artisan::call("queue:retry {$record->uuid}");
|
||||
}
|
||||
Notification::make()
|
||||
->title("{$records->count()} jobs have been pushed back onto the queue.")
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
DeleteAction::make('Delete'),
|
||||
ViewAction::make('View'),
|
||||
Action::make('retry')
|
||||
->label('Retry')
|
||||
->requiresConfirmation()
|
||||
->action(function (FailedJob $record): void {
|
||||
Artisan::call("queue:retry {$record->uuid}");
|
||||
Notification::make()
|
||||
->title("The job with uuid '{$record->uuid}' has been pushed back onto the queue.")
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListFailedJobs::route('/'),
|
||||
'view' => ViewFailedJobs::route('/{record}'),
|
||||
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\FailedJobResource\Pages;
|
||||
|
||||
use App\Filament\Resources\FailedJobResource;
|
||||
use App\Models\FailedJob;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Actions\Action;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
class ListFailedJobs extends ListRecords
|
||||
{
|
||||
protected static string $resource = FailedJobResource::class;
|
||||
|
||||
public function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('retry_all')
|
||||
->label('Retry all failed Jobs')
|
||||
->requiresConfirmation()
|
||||
->action(function (): void {
|
||||
Artisan::call('queue:retry all');
|
||||
Notification::make()
|
||||
->title('All failed jobs have been pushed back onto the queue.')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
|
||||
Action::make('delete_all')
|
||||
->label('Delete all failed Jobs')
|
||||
->requiresConfirmation()
|
||||
->color('danger')
|
||||
->action(function (): void {
|
||||
FailedJob::truncate();
|
||||
Notification::make()
|
||||
->title('All failed jobs have been removed.')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\FailedJobResource\Pages;
|
||||
|
||||
use App\Filament\Resources\FailedJobResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewFailedJobs extends ViewRecord
|
||||
{
|
||||
protected static string $resource = FailedJobResource::class;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Filament\Resources;
|
||||
use App\Filament\Resources\OrganizationResource\Pages;
|
||||
use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager;
|
||||
use App\Models\Organization;
|
||||
use App\Service\Export\ExportService;
|
||||
use App\Service\Import\Importers\ImporterProvider;
|
||||
use App\Service\Import\Importers\ImportException;
|
||||
use App\Service\Import\Importers\ReportDto;
|
||||
@@ -110,6 +111,30 @@ class OrganizationResource extends Resource
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
Action::make('Export')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->action(function (Organization $record) {
|
||||
try {
|
||||
$file = app(ExportService::class)->export($record);
|
||||
Notification::make()
|
||||
->title('Export successful')
|
||||
->success()
|
||||
->persistent()
|
||||
->send();
|
||||
|
||||
return response()->streamDownload(function () use ($file) {
|
||||
echo Storage::disk(config('filesystems.private'))->get($file);
|
||||
}, 'export.zip');
|
||||
} catch (\Exception $exception) {
|
||||
report($exception);
|
||||
Notification::make()
|
||||
->title('Export failed')
|
||||
->danger()
|
||||
->body('Message: '.$exception->getMessage())
|
||||
->persistent()
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
Action::make('Import')
|
||||
->icon('heroicon-o-inbox-arrow-down')
|
||||
->action(function (Organization $record, array $data) {
|
||||
|
||||
@@ -49,7 +49,7 @@ class TimeEntryResource extends Resource
|
||||
->label('End')
|
||||
->nullable()
|
||||
->rules([
|
||||
'after:start',
|
||||
'after_or_equal:start',
|
||||
]),
|
||||
Select::make('user_id')
|
||||
->relationship(name: 'user', titleAttribute: 'email')
|
||||
|
||||
@@ -111,9 +111,18 @@ class UserResource extends Resource
|
||||
->filters([
|
||||
TernaryFilter::make('real_user')
|
||||
->queries(
|
||||
true: fn (Builder $query) => $query->where('is_placeholder', '=', false),
|
||||
false: fn (Builder $query) => $query->where('is_placeholder', '=', true),
|
||||
blank: fn (Builder $query) => $query,
|
||||
true: function (Builder $query): Builder {
|
||||
/** @var Builder<User> $query */
|
||||
return $query->where('is_placeholder', '=', false);
|
||||
},
|
||||
false: function (Builder $query): Builder {
|
||||
/** @var Builder<User> $query */
|
||||
return $query->where('is_placeholder', '=', true);
|
||||
},
|
||||
blank: function (Builder $query): Builder {
|
||||
/** @var Builder<User> $query */
|
||||
return $query;
|
||||
},
|
||||
)
|
||||
->label('Real User?'),
|
||||
TernaryFilter::make('email_verified')
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
@@ -21,7 +22,8 @@ class ActiveUserOverview extends BaseWidget
|
||||
$placeholderUserCount = User::query()->where('is_placeholder', '=', true)->count();
|
||||
$activeInLastWeek = User::query()
|
||||
->where('is_placeholder', '=', false)
|
||||
->whereHas('timeEntries', function (Builder $query) {
|
||||
->whereHas('timeEntries', function (Builder $query): void {
|
||||
/** @var Builder<TimeEntry> $query */
|
||||
$query->where('created_at', '>=', now()->subWeek())
|
||||
->orWhere('updated_at', '>=', now()->subWeek());
|
||||
})
|
||||
|
||||
@@ -66,7 +66,7 @@ class ClientController extends Controller
|
||||
{
|
||||
$this->checkPermission($organization, 'clients:create');
|
||||
|
||||
$client = new Client();
|
||||
$client = new Client;
|
||||
$client->name = $request->input('name');
|
||||
$client->organization()->associate($organization);
|
||||
$client->save();
|
||||
|
||||
@@ -12,8 +12,7 @@ class Controller extends \App\Http\Controllers\Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PermissionStore $permissionStore,
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
@@ -21,7 +20,7 @@ class Controller extends \App\Http\Controllers\Controller
|
||||
protected function checkPermission(Organization $organization, string $permission): void
|
||||
{
|
||||
if (! $this->permissionStore->has($organization, $permission)) {
|
||||
throw new AuthorizationException();
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +36,7 @@ class Controller extends \App\Http\Controllers\Controller
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new AuthorizationException();
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
protected function hasPermission(Organization $organization, string $permission): bool
|
||||
|
||||
38
app/Http/Controllers/Api/V1/ExportController.php
Normal file
38
app/Http/Controllers/Api/V1/ExportController.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Service\Export\ExportException;
|
||||
use App\Service\Export\ExportService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ExportController extends Controller
|
||||
{
|
||||
/**
|
||||
* Export data of an organization
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws ExportException
|
||||
*
|
||||
* @operationId exportOrganization
|
||||
*/
|
||||
public function export(Organization $organization, ExportService $exportService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'export');
|
||||
|
||||
$filepath = $exportService->export($organization);
|
||||
$downloadUrl = Storage::disk(config('filesystems.private'))
|
||||
->temporaryUrl($filepath, Carbon::now()->addMinutes(10));
|
||||
|
||||
return new JsonResponse([
|
||||
'success' => true,
|
||||
'download_url' => $downloadUrl,
|
||||
], 200);
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ class ImportController extends Controller
|
||||
|
||||
foreach ($importers as $key => $importerClass) {
|
||||
/** @var ImporterContract $importer */
|
||||
$importer = new $importerClass();
|
||||
$importer = new $importerClass;
|
||||
$importersResponse[] = [
|
||||
'key' => $key,
|
||||
'name' => $importer->getName(),
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Events\MemberMadeToPlaceholder;
|
||||
use App\Events\MemberRemoved;
|
||||
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
@@ -14,7 +16,6 @@ use App\Exceptions\Api\UserNotPlaceholderApiException;
|
||||
use App\Http\Requests\V1\Member\MemberIndexRequest;
|
||||
use App\Http\Requests\V1\Member\MemberUpdateRequest;
|
||||
use App\Http\Resources\V1\Member\MemberCollection;
|
||||
use App\Http\Resources\V1\Member\MemberPivotResource;
|
||||
use App\Http\Resources\V1\Member\MemberResource;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
@@ -40,7 +41,7 @@ class MemberController extends Controller
|
||||
/**
|
||||
* List all members of an organization
|
||||
*
|
||||
* @return MemberCollection<MemberPivotResource>>
|
||||
* @return MemberCollection<MemberResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
@@ -50,7 +51,9 @@ class MemberController extends Controller
|
||||
{
|
||||
$this->checkPermission($organization, 'members:view');
|
||||
|
||||
$members = $organization->users()
|
||||
$members = Member::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->with(['user'])
|
||||
->paginate(config('app.pagination_per_page_default'));
|
||||
|
||||
return MemberCollection::make($members);
|
||||
@@ -79,16 +82,16 @@ class MemberController extends Controller
|
||||
$newRole = $request->getRole();
|
||||
$oldRole = Role::from($member->role);
|
||||
if ($oldRole === Role::Owner) {
|
||||
throw new OrganizationNeedsAtLeastOneOwner();
|
||||
throw new OrganizationNeedsAtLeastOneOwner;
|
||||
}
|
||||
if ($newRole === Role::Placeholder) {
|
||||
throw new ChangingRoleToPlaceholderIsNotAllowed();
|
||||
throw new ChangingRoleToPlaceholderIsNotAllowed;
|
||||
}
|
||||
if ($newRole === Role::Owner) {
|
||||
if ($this->hasPermission($organization, 'members:change-ownership')) {
|
||||
$memberService->changeOwnership($organization, $member);
|
||||
} else {
|
||||
throw new OnlyOwnerCanChangeOwnership();
|
||||
throw new OnlyOwnerCanChangeOwnership;
|
||||
}
|
||||
} else {
|
||||
$member->role = $request->getRole()->value;
|
||||
@@ -117,15 +120,34 @@ class MemberController extends Controller
|
||||
throw new EntityStillInUseApiException('member', 'project_member');
|
||||
}
|
||||
if ($member->role === Role::Owner->value) {
|
||||
throw new CanNotRemoveOwnerFromOrganization();
|
||||
throw new CanNotRemoveOwnerFromOrganization;
|
||||
}
|
||||
|
||||
$member->delete();
|
||||
MemberRemoved::dispatch($member, $organization);
|
||||
|
||||
return response()
|
||||
->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization
|
||||
*/
|
||||
public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'members:make-placeholder', $member);
|
||||
|
||||
if ($member->role === Role::Owner->value) {
|
||||
throw new CanNotRemoveOwnerFromOrganization;
|
||||
}
|
||||
|
||||
$memberService->makeMemberToPlaceholder($member);
|
||||
|
||||
MemberMadeToPlaceholder::dispatch($member, $organization);
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite a placeholder member to become a real member of the organization
|
||||
*
|
||||
@@ -139,7 +161,7 @@ class MemberController extends Controller
|
||||
$user = $member->user;
|
||||
|
||||
if (! $user->is_placeholder) {
|
||||
throw new UserNotPlaceholderApiException();
|
||||
throw new UserNotPlaceholderApiException;
|
||||
}
|
||||
|
||||
$invitationService->inviteUser($organization, $user->email, Role::Employee);
|
||||
|
||||
@@ -89,7 +89,7 @@ class ProjectController extends Controller
|
||||
public function store(Organization $organization, ProjectStoreRequest $request): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'projects:create');
|
||||
$project = new Project();
|
||||
$project = new Project;
|
||||
$project->name = $request->input('name');
|
||||
$project->color = $request->input('color');
|
||||
$project->is_billable = (bool) $request->input('is_billable');
|
||||
|
||||
@@ -59,25 +59,29 @@ class ProjectMemberController extends Controller
|
||||
*
|
||||
* @operationId createProjectMember
|
||||
*/
|
||||
public function store(Organization $organization, Project $project, ProjectMemberStoreRequest $request): JsonResource
|
||||
public function store(Organization $organization, Project $project, ProjectMemberStoreRequest $request, BillableRateService $billableRateService): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'project-members:create', $project);
|
||||
|
||||
$member = Member::findOrFail((string) $request->input('member_id'));
|
||||
if ($member->user->is_placeholder) {
|
||||
throw new InactiveUserCanNotBeUsedApiException();
|
||||
throw new InactiveUserCanNotBeUsedApiException;
|
||||
}
|
||||
if (ProjectMember::whereBelongsTo($project, 'project')->whereBelongsTo($member, 'member')->exists()) {
|
||||
throw new UserIsAlreadyMemberOfProjectApiException();
|
||||
throw new UserIsAlreadyMemberOfProjectApiException;
|
||||
}
|
||||
|
||||
$projectMember = new ProjectMember();
|
||||
$projectMember = new ProjectMember;
|
||||
$projectMember->billable_rate = $request->getBillableRate();
|
||||
$projectMember->member()->associate($member);
|
||||
$projectMember->user()->associate($member->user);
|
||||
$projectMember->project()->associate($project);
|
||||
$projectMember->save();
|
||||
|
||||
if ($request->getBillableRate() !== null) {
|
||||
$billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);
|
||||
}
|
||||
|
||||
return new ProjectMemberResource($projectMember);
|
||||
}
|
||||
|
||||
@@ -109,12 +113,22 @@ class ProjectMemberController extends Controller
|
||||
*
|
||||
* @operationId deleteProjectMember
|
||||
*/
|
||||
public function destroy(Organization $organization, ProjectMember $projectMember): JsonResponse
|
||||
public function destroy(Organization $organization, ProjectMember $projectMember, BillableRateService $billableRateService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'project-members:delete', projectMember: $projectMember);
|
||||
|
||||
$hadBillableRate = $projectMember->billable_rate !== null;
|
||||
$project = $projectMember->project;
|
||||
$member = $projectMember->member;
|
||||
|
||||
$projectMember->delete();
|
||||
|
||||
if ($hadBillableRate) {
|
||||
$billableRateService->updateTimeEntriesBillableRateForMember($member);
|
||||
$billableRateService->updateTimeEntriesBillableRateForProject($project);
|
||||
$billableRateService->updateTimeEntriesBillableRateForOrganization($organization);
|
||||
}
|
||||
|
||||
return response()
|
||||
->json(null, 204);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ class TagController extends Controller
|
||||
{
|
||||
$this->checkPermission($organization, 'tags:create');
|
||||
|
||||
$tag = new Tag();
|
||||
$tag = new Tag;
|
||||
$tag->name = $request->input('name');
|
||||
$tag->organization()->associate($organization);
|
||||
$tag->save();
|
||||
|
||||
@@ -76,7 +76,7 @@ class TaskController extends Controller
|
||||
public function store(Organization $organization, TaskStoreRequest $request): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'tasks:create');
|
||||
$task = new Task();
|
||||
$task = new Task;
|
||||
$task->name = $request->input('name');
|
||||
$task->project_id = $request->input('project_id');
|
||||
$task->organization()->associate($organization);
|
||||
|
||||
@@ -212,12 +212,12 @@ class TimeEntryController extends Controller
|
||||
}
|
||||
|
||||
if ($request->input('end') === null && TimeEntry::query()->whereBelongsTo($member, 'member')->where('end', null)->exists()) {
|
||||
throw new TimeEntryStillRunningApiException();
|
||||
throw new TimeEntryStillRunningApiException;
|
||||
}
|
||||
|
||||
$client = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id'))->client : null;
|
||||
|
||||
$timeEntry = new TimeEntry();
|
||||
$timeEntry = new TimeEntry;
|
||||
$timeEntry->fill($request->validated());
|
||||
$timeEntry->client()->associate($client);
|
||||
$timeEntry->user_id = $member->user_id;
|
||||
@@ -247,7 +247,7 @@ class TimeEntryController extends Controller
|
||||
}
|
||||
|
||||
if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {
|
||||
throw new TimeEntryCanNotBeRestartedApiException();
|
||||
throw new TimeEntryCanNotBeRestartedApiException;
|
||||
}
|
||||
|
||||
if ($request->has('project_id')) {
|
||||
@@ -275,17 +275,17 @@ class TimeEntryController extends Controller
|
||||
$this->checkAnyPermission($organization, ['time-entries:update:all', 'time-entries:update:own']);
|
||||
$canAccessAll = $this->hasPermission($organization, 'time-entries:update:all');
|
||||
|
||||
$ids = $request->input('ids');
|
||||
$ids = $request->validated('ids');
|
||||
|
||||
$timeEntries = TimeEntry::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->whereIn('id', $ids)
|
||||
->get();
|
||||
|
||||
$changes = $request->input('changes');
|
||||
$changes = $request->validated('changes');
|
||||
|
||||
if (isset($changes['member_id']) && ! $canAccessAll && $this->member($organization)->getKey() !== $changes['member_id']) {
|
||||
throw new AuthorizationException();
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
$client = null;
|
||||
@@ -295,10 +295,11 @@ class TimeEntryController extends Controller
|
||||
$overwriteClient = true;
|
||||
}
|
||||
|
||||
$success = new Collection();
|
||||
$error = new Collection();
|
||||
$success = new Collection;
|
||||
$error = new Collection;
|
||||
|
||||
foreach ($ids as $id) {
|
||||
/** @var TimeEntry|null $timeEntry */
|
||||
$timeEntry = $timeEntries->firstWhere('id', $id);
|
||||
if ($timeEntry === null) {
|
||||
// Note: ID wrong or time entry in different organization
|
||||
@@ -316,6 +317,7 @@ class TimeEntryController extends Controller
|
||||
if ($overwriteClient) {
|
||||
$timeEntry->client()->associate($client);
|
||||
}
|
||||
$timeEntry->setComputedAttributeValue('billable_rate');
|
||||
$timeEntry->save();
|
||||
$success->push($id);
|
||||
}
|
||||
|
||||
28
app/Http/Controllers/Api/V1/UserController.php
Normal file
28
app/Http/Controllers/Api/V1/UserController.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Resources\V1\User\UserResource;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
/**
|
||||
* Get the current user
|
||||
*
|
||||
* This endpoint is independent of organization.
|
||||
*
|
||||
* @operationId getMe
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function me(): JsonResource
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
return new UserResource($user);
|
||||
}
|
||||
}
|
||||
36
app/Http/Controllers/Api/V1/UserMembershipController.php
Normal file
36
app/Http/Controllers/Api/V1/UserMembershipController.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Resources\V1\Member\PersonalMembershipCollection;
|
||||
use App\Models\Member;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class UserMembershipController extends Controller
|
||||
{
|
||||
/**
|
||||
* Get the memberships of the current user
|
||||
*
|
||||
* This endpoint is independent of organization.
|
||||
*
|
||||
* @operationId getMyMemberships
|
||||
*
|
||||
* @return PersonalMembershipCollection
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function myMemberships(): JsonResource
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
$members = Member::query()
|
||||
->whereBelongsTo($user, 'user')
|
||||
->with(['organization'])
|
||||
->get();
|
||||
|
||||
return new PersonalMembershipCollection($members);
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ class Controller extends BaseController
|
||||
$user = Auth::user();
|
||||
if ($user === null) {
|
||||
Log::error('This function should only be called in authenticated context');
|
||||
throw new AuthorizationException();
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
return $user;
|
||||
@@ -44,7 +44,7 @@ class Controller extends BaseController
|
||||
$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');
|
||||
throw new AuthorizationException();
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
return $member;
|
||||
|
||||
@@ -4,6 +4,4 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
abstract class Controller extends \App\Http\Controllers\Controller
|
||||
{
|
||||
}
|
||||
abstract class Controller extends \App\Http\Controllers\Controller {}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http;
|
||||
|
||||
use App\Http\Middleware\CheckOrganizationBlocked;
|
||||
use App\Http\Middleware\ForceJsonResponse;
|
||||
use Illuminate\Foundation\Http\Kernel as HttpKernel;
|
||||
|
||||
@@ -50,6 +51,9 @@ class Kernel extends HttpKernel
|
||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||
ForceJsonResponse::class,
|
||||
],
|
||||
|
||||
'health-check' => [
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -71,5 +75,6 @@ class Kernel extends HttpKernel
|
||||
'signed' => \App\Http\Middleware\ValidateSignature::class,
|
||||
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
||||
'verified' => \App\Http\Middleware\EnsureEmailIsVerified::class,
|
||||
'check-organization-blocked' => CheckOrganizationBlocked::class,
|
||||
];
|
||||
}
|
||||
|
||||
40
app/Http/Middleware/CheckOrganizationBlocked.php
Normal file
40
app/Http/Middleware/CheckOrganizationBlocked.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
|
||||
use App\Models\Organization;
|
||||
use App\Service\BillingContract;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class CheckOrganizationBlocked
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*
|
||||
* @throws OrganizationHasNoSubscriptionButMultipleMembersException
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$organization = $request->route('organization');
|
||||
|
||||
if (! ($organization instanceof Organization)) {
|
||||
throw new \LogicException('The organization must be loaded before this middleware.');
|
||||
}
|
||||
|
||||
/** @var BillingContract $billing */
|
||||
$billing = app(BillingContract::class);
|
||||
|
||||
if ($billing->isBlocked($organization)) {
|
||||
throw new OrganizationHasNoSubscriptionButMultipleMembersException;
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -40,18 +40,19 @@ class HandleInertiaRequests extends Middleware
|
||||
public function share(Request $request): array
|
||||
{
|
||||
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
|
||||
$billing = null;
|
||||
if ($hasBilling) {
|
||||
/** @var BillingContract $billing */
|
||||
$billing = app(BillingContract::class);
|
||||
}
|
||||
|
||||
/** @var BillingContract $billing */
|
||||
$billing = app(BillingContract::class);
|
||||
|
||||
$currentOrganization = $request->user()?->currentTeam;
|
||||
|
||||
return array_merge(parent::share($request), [
|
||||
'has_billing_extension' => $hasBilling,
|
||||
'billing' => $billing !== null ? [
|
||||
'has_subscription' => $currentOrganization !== null ? $billing->hasSubscription($currentOrganization) : null,
|
||||
'billing' => $billing !== null && $currentOrganization !== null ? [
|
||||
'has_subscription' => $billing->hasSubscription($currentOrganization),
|
||||
'has_trial' => $billing->hasTrial($currentOrganization),
|
||||
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),
|
||||
'is_blocked' => $billing->isBlocked($currentOrganization),
|
||||
] : null,
|
||||
'flash' => [
|
||||
'message' => fn () => $request->session()->get('message'),
|
||||
|
||||
@@ -41,7 +41,7 @@ class ProjectStoreRequest extends FormRequest
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
new ColorRule(),
|
||||
new ColorRule,
|
||||
],
|
||||
'is_billable' => [
|
||||
'required',
|
||||
|
||||
@@ -41,7 +41,7 @@ class ProjectUpdateRequest extends FormRequest
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
new ColorRule(),
|
||||
new ColorRule,
|
||||
],
|
||||
'is_billable' => [
|
||||
'required',
|
||||
|
||||
@@ -71,7 +71,7 @@ class TimeEntryStoreRequest extends FormRequest
|
||||
'end' => [
|
||||
'nullable',
|
||||
'date_format:Y-m-d\TH:i:s\Z',
|
||||
'after:start',
|
||||
'after_or_equal:start',
|
||||
],
|
||||
// Whether time entry is billable
|
||||
'billable' => [
|
||||
|
||||
@@ -70,7 +70,7 @@ class TimeEntryUpdateRequest extends FormRequest
|
||||
'end' => [
|
||||
'nullable',
|
||||
'date_format:Y-m-d\TH:i:s\Z',
|
||||
'after:start',
|
||||
'after_or_equal:start',
|
||||
],
|
||||
// Whether time entry is billable
|
||||
'billable' => [
|
||||
|
||||
@@ -4,6 +4,4 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
interface PaginatedResourceCollection
|
||||
{
|
||||
}
|
||||
interface PaginatedResourceCollection {}
|
||||
|
||||
@@ -12,5 +12,6 @@ abstract class BaseResource extends JsonResource
|
||||
protected function formatDateTime(?Carbon $carbon): ?string
|
||||
{
|
||||
return $carbon?->toIso8601ZuluString();
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,5 +14,5 @@ class MemberCollection extends ResourceCollection implements PaginatedResourceCo
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $collects = MemberPivotResource::class;
|
||||
public $collects = MemberResource::class;
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Member;
|
||||
|
||||
use App\Http\Resources\V1\BaseResource;
|
||||
use App\Models\Member;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* @property User $resource
|
||||
*/
|
||||
class MemberPivotResource extends BaseResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, string|bool|int|null|array<string>>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
/** @var Member $member */
|
||||
$member = $this->resource->getRelationValue('membership');
|
||||
|
||||
return [
|
||||
/** @var string $id ID of membership */
|
||||
'id' => $member->id,
|
||||
/** @var string $id ID of user */
|
||||
'user_id' => $this->resource->id,
|
||||
/** @var string $name Name */
|
||||
'name' => $this->resource->name,
|
||||
/** @var string $email Email */
|
||||
'email' => $this->resource->email,
|
||||
/** @var string $role Role */
|
||||
'role' => $member->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' => $member->billable_rate,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Member;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class PersonalMembershipCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $collects = PersonalMembershipResource::class;
|
||||
}
|
||||
36
app/Http/Resources/V1/Member/PersonalMembershipResource.php
Normal file
36
app/Http/Resources/V1/Member/PersonalMembershipResource.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Member;
|
||||
|
||||
use App\Http\Resources\V1\BaseResource;
|
||||
use App\Models\Member;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* @property Member $resource
|
||||
*/
|
||||
class PersonalMembershipResource extends BaseResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, string|bool|int|null|array<string>>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** @var string $id ID of membership */
|
||||
'id' => $this->resource->id,
|
||||
'organization' => [
|
||||
/** @var string $id ID of organization */
|
||||
'id' => $this->resource->organization->id,
|
||||
/** @var string $name Name of organization */
|
||||
'name' => $this->resource->organization->name,
|
||||
],
|
||||
/** @var string $role Role */
|
||||
'role' => $this->resource->role,
|
||||
];
|
||||
}
|
||||
}
|
||||
39
app/Http/Resources/V1/User/UserResource.php
Normal file
39
app/Http/Resources/V1/User/UserResource.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\User;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Http\Resources\V1\BaseResource;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* @property User $resource
|
||||
*/
|
||||
class UserResource extends BaseResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, string|bool|int|null|array<string>>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** @var string $id ID of user */
|
||||
'id' => $this->resource->id,
|
||||
/** @var string $name Name of user */
|
||||
'name' => $this->resource->name,
|
||||
/** @var string $email Email of user */
|
||||
'email' => $this->resource->email,
|
||||
/** @var string $profile_photo_url Profile photo URL */
|
||||
'profile_photo_url' => $this->resource->profile_photo_url,
|
||||
/** @var string $timezone Timezone (f.e. Europe/Berlin or America/New_York) */
|
||||
'timezone' => $this->resource->timezone,
|
||||
/** @var Weekday $week_start Starting day of the week */
|
||||
'week_start' => $this->resource->week_start->value,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\User;
|
||||
use App\Service\UserService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
||||
@@ -19,7 +20,8 @@ class RemovePlaceholder
|
||||
/** @var UserService $userService */
|
||||
$userService = app(UserService::class);
|
||||
$placeholders = Member::query()
|
||||
->whereHas('user', function (Builder $query) use ($event) {
|
||||
->whereHas('user', function (Builder $query) use ($event): void {
|
||||
/** @var Builder<User> $query */
|
||||
$query->where('is_placeholder', '=', true)
|
||||
->where('email', '=', $event->user->email);
|
||||
})
|
||||
|
||||
43
app/Mail/TimeEntryStillRunningMail.php
Normal file
43
app/Mail/TimeEntryStillRunningMail.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class TimeEntryStillRunningMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public TimeEntry $timeEntry;
|
||||
|
||||
public User $user;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(TimeEntry $timeEntry, User $user)
|
||||
{
|
||||
$this->timeEntry = $timeEntry;
|
||||
$this->user = $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the message.
|
||||
*/
|
||||
public function build(): self
|
||||
{
|
||||
return $this->markdown('emails.time-entry-still-running', [
|
||||
'dashboardUrl' => URL::route('dashboard'),
|
||||
])
|
||||
->subject(__('Your Time Tracker is still running!'));
|
||||
}
|
||||
}
|
||||
34
app/Models/Audit.php
Normal file
34
app/Models/Audit.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\AuditFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Support\Carbon;
|
||||
use OwenIt\Auditing\Models\Audit as PackageAuditModel;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string|null $user_type
|
||||
* @property string|null $user_id
|
||||
* @property string $event
|
||||
* @property string $auditable_type
|
||||
* @property string $auditable_id
|
||||
* @property array|null $old_values
|
||||
* @property array|null $new_values
|
||||
* @property string|null $url
|
||||
* @property string|null $ip_address
|
||||
* @property string|null $user_agent
|
||||
* @property string|null $tags
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
*
|
||||
* @method static AuditFactory factory()
|
||||
*/
|
||||
class Audit extends PackageAuditModel
|
||||
{
|
||||
/** @use HasFactory<AuditFactory> */
|
||||
use HasFactory;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\CustomAuditable;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\ClientFactory;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
@@ -12,6 +13,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
@@ -25,9 +27,13 @@ use Illuminate\Support\Carbon;
|
||||
*
|
||||
* @method static ClientFactory factory()
|
||||
*/
|
||||
class Client extends Model
|
||||
class Client extends Model implements AuditableContract
|
||||
{
|
||||
use CustomAuditable;
|
||||
|
||||
/** @use HasFactory<ClientFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use HasUuids;
|
||||
|
||||
/**
|
||||
@@ -37,6 +43,7 @@ class Client extends Model
|
||||
*/
|
||||
protected $casts = [
|
||||
'name' => 'string',
|
||||
'archived_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
22
app/Models/Concerns/CustomAuditable.php
Normal file
22
app/Models/Concerns/CustomAuditable.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use OwenIt\Auditing\Auditable;
|
||||
|
||||
trait CustomAuditable
|
||||
{
|
||||
use Auditable;
|
||||
|
||||
/**
|
||||
* @var array<string>|null
|
||||
*/
|
||||
protected ?array $auditEvents = null;
|
||||
|
||||
public function disableAuditing(): void
|
||||
{
|
||||
$this->auditEvents = [];
|
||||
}
|
||||
}
|
||||
39
app/Models/FailedJob.php
Normal file
39
app/Models/FailedJob.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\FailedJobFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property string $uuid
|
||||
* @property string $connection
|
||||
* @property string $queue
|
||||
* @property Carbon $failed_at
|
||||
*/
|
||||
class FailedJob extends Model
|
||||
{
|
||||
/** @use HasFactory<FailedJobFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = false;
|
||||
|
||||
/**
|
||||
* The attributes that should be cast to native types.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'failed_at' => 'datetime',
|
||||
'payload' => 'json',
|
||||
];
|
||||
}
|
||||
@@ -4,12 +4,15 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\CustomAuditable;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\MemberFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Laravel\Jetstream\Membership as JetstreamMembership;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
@@ -17,16 +20,20 @@ use Laravel\Jetstream\Membership as JetstreamMembership;
|
||||
* @property int|null $billable_rate
|
||||
* @property string $organization_id
|
||||
* @property string $user_id
|
||||
* @property string $created_at
|
||||
* @property string $updated_at
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read Organization $organization
|
||||
* @property-read User $user
|
||||
*
|
||||
* @method static MemberFactory factory()
|
||||
*/
|
||||
class Member extends JetstreamMembership
|
||||
class Member extends JetstreamMembership implements AuditableContract
|
||||
{
|
||||
use CustomAuditable;
|
||||
|
||||
/** @use HasFactory<MemberFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use HasUuids;
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,11 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\CustomAuditable;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\OrganizationFactory;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -18,6 +20,7 @@ use Laravel\Jetstream\Events\TeamDeleted;
|
||||
use Laravel\Jetstream\Events\TeamUpdated;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
use Laravel\Jetstream\Team as JetstreamTeam;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
@@ -37,9 +40,13 @@ use Laravel\Jetstream\Team as JetstreamTeam;
|
||||
* @method HasMany<OrganizationInvitation> teamInvitations()
|
||||
* @method static OrganizationFactory factory()
|
||||
*/
|
||||
class Organization extends JetstreamTeam
|
||||
class Organization extends JetstreamTeam implements AuditableContract
|
||||
{
|
||||
use CustomAuditable;
|
||||
|
||||
/** @use HasFactory<OrganizationFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use HasUuids;
|
||||
|
||||
/**
|
||||
@@ -107,7 +114,7 @@ class Organization extends JetstreamTeam
|
||||
*/
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Jetstream::userModel(), Jetstream::membershipModel())
|
||||
return $this->belongsToMany(User::class, Member::class)
|
||||
->withPivot([
|
||||
'id',
|
||||
'role',
|
||||
@@ -117,6 +124,24 @@ class Organization extends JetstreamTeam
|
||||
->as('membership');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the owner of the team.
|
||||
*
|
||||
* @return BelongsTo<User, Organization>
|
||||
*/
|
||||
public function owner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Member>
|
||||
*/
|
||||
public function members(): HasMany
|
||||
{
|
||||
return $this->hasMany(Member::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<User>
|
||||
*/
|
||||
|
||||
@@ -4,25 +4,33 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\CustomAuditable;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\OrganizationInvitationFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Laravel\Jetstream\TeamInvitation as JetstreamTeamInvitation;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
* @property string $email
|
||||
* @property string $role
|
||||
* @property string $organization_id
|
||||
* @property Carbon|null $updated_at
|
||||
* @property Carbon|null $created_at
|
||||
* @property-read Organization $organization
|
||||
*
|
||||
* @method static OrganizationInvitationFactory factory()
|
||||
*/
|
||||
class OrganizationInvitation extends JetstreamTeamInvitation
|
||||
class OrganizationInvitation extends JetstreamTeamInvitation implements AuditableContract
|
||||
{
|
||||
use CustomAuditable;
|
||||
|
||||
/** @use HasFactory<OrganizationInvitationFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use HasUuids;
|
||||
|
||||
/**
|
||||
@@ -49,7 +57,7 @@ class OrganizationInvitation extends JetstreamTeamInvitation
|
||||
*/
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Jetstream::teamModel(), 'organization_id');
|
||||
return $this->belongsTo(Organization::class, 'organization_id');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,6 +67,6 @@ class OrganizationInvitation extends JetstreamTeamInvitation
|
||||
*/
|
||||
public function team(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Jetstream::teamModel(), 'organization_id');
|
||||
return $this->belongsTo(Organization::class, 'organization_id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\CustomAuditable;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\ProjectFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
@@ -22,6 +24,7 @@ use Illuminate\Support\Carbon;
|
||||
* @property string $organization_id
|
||||
* @property string $client_id
|
||||
* @property int|null $billable_rate
|
||||
* @property bool $is_public
|
||||
* @property bool $is_billable
|
||||
* @property-read bool $is_archived
|
||||
* @property Carbon|null $archived_at
|
||||
@@ -35,9 +38,13 @@ use Illuminate\Support\Carbon;
|
||||
* @method Builder<Project> visibleByEmployee(User $user)
|
||||
* @method static ProjectFactory factory()
|
||||
*/
|
||||
class Project extends Model
|
||||
class Project extends Model implements AuditableContract
|
||||
{
|
||||
use CustomAuditable;
|
||||
|
||||
/** @use HasFactory<ProjectFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use HasUuids;
|
||||
|
||||
/**
|
||||
@@ -48,6 +55,7 @@ class Project extends Model
|
||||
protected $casts = [
|
||||
'name' => 'string',
|
||||
'color' => 'string',
|
||||
'archived_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,12 +4,15 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\CustomAuditable;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\ProjectMemberFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
@@ -17,6 +20,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
* @property string $project_id Project ID
|
||||
* @property string $member_id Member ID
|
||||
* @property string $user_id User ID (legacy)
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read Project $project
|
||||
* @property-read Member $member
|
||||
* @property-read User $user
|
||||
@@ -24,9 +29,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
* @method static Builder<ProjectMember> whereBelongsToOrganization(Organization $organization)
|
||||
* @method static ProjectMemberFactory factory()
|
||||
*/
|
||||
class ProjectMember extends Model
|
||||
class ProjectMember extends Model implements AuditableContract
|
||||
{
|
||||
use CustomAuditable;
|
||||
|
||||
/** @use HasFactory<ProjectMemberFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use HasUuids;
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,12 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\CustomAuditable;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\TagFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
@@ -21,9 +23,13 @@ use Illuminate\Support\Carbon;
|
||||
*
|
||||
* @method static TagFactory factory()
|
||||
*/
|
||||
class Tag extends Model
|
||||
class Tag extends Model implements AuditableContract
|
||||
{
|
||||
use CustomAuditable;
|
||||
|
||||
/** @use HasFactory<TagFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use HasUuids;
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\CustomAuditable;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\TaskFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
@@ -30,9 +32,13 @@ use Illuminate\Support\Carbon;
|
||||
*
|
||||
* @method static TaskFactory factory()
|
||||
*/
|
||||
class Task extends Model
|
||||
class Task extends Model implements AuditableContract
|
||||
{
|
||||
use CustomAuditable;
|
||||
|
||||
/** @use HasFactory<TaskFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use HasUuids;
|
||||
|
||||
/**
|
||||
@@ -42,6 +48,7 @@ class Task extends Model
|
||||
*/
|
||||
protected $casts = [
|
||||
'name' => 'string',
|
||||
'done_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\CustomAuditable;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use App\Service\BillableRateService;
|
||||
use Carbon\CarbonInterval;
|
||||
@@ -14,18 +15,22 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Korridor\LaravelComputedAttributes\ComputedAttributes;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
* @property string $description
|
||||
* @property Carbon $start
|
||||
* @property Carbon|null $end
|
||||
* @property int $billable_rate Billable rate per hour in cents
|
||||
* @property int|null $billable_rate Billable rate per hour in cents
|
||||
* @property bool $billable
|
||||
* @property array $tags
|
||||
* @property string $user_id
|
||||
* @property string $member_id
|
||||
* @property bool $is_imported
|
||||
* @property Carbon|null $still_active_email_sent_at
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read User $user
|
||||
* @property-read Member $member
|
||||
* @property string $organization_id
|
||||
@@ -40,10 +45,14 @@ use Korridor\LaravelComputedAttributes\ComputedAttributes;
|
||||
* @method Builder<TimeEntry> hasTag(Tag $tag)
|
||||
* @method static TimeEntryFactory factory()
|
||||
*/
|
||||
class TimeEntry extends Model
|
||||
class TimeEntry extends Model implements AuditableContract
|
||||
{
|
||||
use ComputedAttributes;
|
||||
use CustomAuditable;
|
||||
|
||||
/** @use HasFactory<TimeEntryFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use HasUuids;
|
||||
|
||||
/**
|
||||
@@ -59,6 +68,7 @@ class TimeEntry extends Model
|
||||
'tags' => 'array',
|
||||
'billable_rate' => 'int',
|
||||
'is_imported' => 'bool',
|
||||
'still_active_email_sent_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\Concerns\CustomAuditable;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\UserFactory;
|
||||
use Filament\Models\Contracts\FilamentUser;
|
||||
@@ -25,6 +26,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Laravel\Jetstream\HasProfilePhoto;
|
||||
use Laravel\Jetstream\HasTeams;
|
||||
use Laravel\Passport\HasApiTokens;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
@@ -53,10 +55,14 @@ use Laravel\Passport\HasApiTokens;
|
||||
* @method Builder<User> belongsToOrganization(Organization $organization)
|
||||
* @method Builder<User> active()
|
||||
*/
|
||||
class User extends Authenticatable implements FilamentUser, MustVerifyEmail
|
||||
class User extends Authenticatable implements AuditableContract, FilamentUser, MustVerifyEmail
|
||||
{
|
||||
use CustomAuditable;
|
||||
use HasApiTokens;
|
||||
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use HasProfilePhoto;
|
||||
use HasTeams;
|
||||
use HasUuids;
|
||||
|
||||
@@ -5,10 +5,12 @@ declare(strict_types=1);
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\FailedJob;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
@@ -22,6 +24,7 @@ use Dedoc\Scramble\Support\Generator\OpenApi;
|
||||
use Dedoc\Scramble\Support\Generator\SecurityScheme;
|
||||
use Dedoc\Scramble\Support\Generator\SecuritySchemes\OAuthFlow;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Foundation\Application;
|
||||
@@ -50,25 +53,34 @@ class AppServiceProvider extends ServiceProvider
|
||||
$this->app->register(TelescopeServiceProvider::class);
|
||||
}
|
||||
|
||||
// Eloquent
|
||||
Model::preventLazyLoading(! $this->app->isProduction());
|
||||
Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction());
|
||||
Model::preventAccessingMissingAttributes(! $this->app->isProduction());
|
||||
Relation::enforceMorphMap([
|
||||
'client' => Client::class,
|
||||
'failed-job' => FailedJob::class,
|
||||
'membership' => Member::class,
|
||||
'organization' => Organization::class,
|
||||
'organization-invitation' => OrganizationInvitation::class,
|
||||
'user' => User::class,
|
||||
'time-entry' => TimeEntry::class,
|
||||
'project' => Project::class,
|
||||
'task' => Task::class,
|
||||
'client' => Client::class,
|
||||
'project-member' => ProjectMember::class,
|
||||
'tag' => Tag::class,
|
||||
'task' => Task::class,
|
||||
'time-entry' => TimeEntry::class,
|
||||
'user' => User::class,
|
||||
]);
|
||||
Model::unguard();
|
||||
|
||||
// Filament
|
||||
Section::configureUsing(function (Section $section): void {
|
||||
$section->columns(1);
|
||||
}, null, true);
|
||||
Table::configureUsing(function (Table $table): void {
|
||||
$table->paginated([10, 25, 50, 100]);
|
||||
});
|
||||
|
||||
// Scramble
|
||||
Scramble::extendOpenApi(function (OpenApi $openApi) {
|
||||
$openApi->secure(
|
||||
SecurityScheme::oauth2()
|
||||
@@ -85,13 +97,14 @@ class AppServiceProvider extends ServiceProvider
|
||||
}
|
||||
|
||||
$this->app->scoped(PermissionStore::class, function (Application $app): PermissionStore {
|
||||
return new PermissionStore();
|
||||
return new PermissionStore;
|
||||
});
|
||||
|
||||
// Extensions
|
||||
$this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class);
|
||||
$this->app->bind(BillingContract::class);
|
||||
|
||||
// Routing
|
||||
Route::model('member', Member::class);
|
||||
Route::model('invitation', OrganizationInvitation::class);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,9 @@ class AdminPanelProvider extends PanelProvider
|
||||
NavigationGroup::make()
|
||||
->label('Users')
|
||||
->collapsed(),
|
||||
NavigationGroup::make()
|
||||
->label('System')
|
||||
->collapsed(),
|
||||
])
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
|
||||
@@ -65,7 +65,7 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
return Limit::perMinute(5)->by($request->session()->get('login.id'));
|
||||
});
|
||||
|
||||
$this->app->instance(LoginResponse::class, new CustomLoginResponse());
|
||||
$this->app->instance(TwoFactorLoginResponse::class, new CustomTwoFactorLoginResponse());
|
||||
$this->app->instance(LoginResponse::class, new CustomLoginResponse);
|
||||
$this->app->instance(TwoFactorLoginResponse::class, new CustomTwoFactorLoginResponse);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'organizations:update',
|
||||
'organizations:delete',
|
||||
'import',
|
||||
'export',
|
||||
'invitations:view',
|
||||
'invitations:create',
|
||||
'invitations:resend',
|
||||
@@ -121,8 +122,10 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'members:view',
|
||||
'members:invite-placeholder',
|
||||
'members:change-ownership',
|
||||
'members:make-placeholder',
|
||||
'members:update',
|
||||
'members:delete',
|
||||
'billing',
|
||||
])->description('Owner users can perform any action. There is only one owner per organization.');
|
||||
|
||||
Jetstream::role(Role::Admin->value, 'Administrator', [
|
||||
@@ -159,6 +162,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'organizations:view',
|
||||
'organizations:update',
|
||||
'import',
|
||||
'export',
|
||||
'invitations:view',
|
||||
'invitations:create',
|
||||
'invitations:resend',
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Http\Controllers\Web\HealthCheckController;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -37,6 +38,12 @@ class RouteServiceProvider extends ServiceProvider
|
||||
});
|
||||
|
||||
$this->routes(function () {
|
||||
Route::middleware('health-check')
|
||||
->group(function () {
|
||||
Route::get('health-check/up', [HealthCheckController::class, 'up']);
|
||||
Route::get('health-check/debug', [HealthCheckController::class, 'debug']);
|
||||
});
|
||||
|
||||
Route::middleware('api')
|
||||
->prefix('api')
|
||||
->name('api.')
|
||||
|
||||
@@ -5,11 +5,53 @@ declare(strict_types=1);
|
||||
namespace App\Service;
|
||||
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* This class is a contract for the billing system
|
||||
* The billing system is responsible for managing the subscriptions of organizations
|
||||
* The concrete implementation of this contract for the cloud version of solidtime is implemented in an extension
|
||||
*/
|
||||
class BillingContract
|
||||
{
|
||||
/**
|
||||
* Check if the organization has a Professional subscription
|
||||
* A Professional subscription is a paid subscription that allows the organization to:
|
||||
* - Have more than 1 non-placeholder member
|
||||
* - Access features that are not available to free organizations
|
||||
*/
|
||||
public function hasSubscription(Organization $organization): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the organization has a trial subscription
|
||||
* A trial subscription gives the organization the same benefits as a Professional subscription, but for a limited time
|
||||
*/
|
||||
public function hasTrial(Organization $organization): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the date until which the organization's trial subscription is valid
|
||||
* If the organization does not have a trial subscription, this method should return null
|
||||
*/
|
||||
public function getTrialUntil(Organization $organization): ?Carbon
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the organization is blocked
|
||||
* A blocked organization is an organization that has more than 1 non-placeholder member but no subscription/trial
|
||||
* This can happen if:
|
||||
* - The organization's trial has expired and during the trial the organization added non-placeholder members
|
||||
* - The organization's subscription has expired and the organization has more than 1 non-placeholder member
|
||||
*/
|
||||
public function isBlocked(Organization $organization): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class DashboardService
|
||||
*/
|
||||
private function lastDays(int $days, CarbonTimeZone $timeZone): Collection
|
||||
{
|
||||
$result = new Collection();
|
||||
$result = new Collection;
|
||||
$date = Carbon::now($timeZone)->subDays($days);
|
||||
for ($i = 0; $i < $days; $i++) {
|
||||
$date->addDay();
|
||||
@@ -77,7 +77,7 @@ class DashboardService
|
||||
*/
|
||||
private function daysOfThisWeek(CarbonTimeZone $timeZone, Weekday $startOfWeek): Collection
|
||||
{
|
||||
$result = new Collection();
|
||||
$result = new Collection;
|
||||
$date = Carbon::now($timeZone);
|
||||
$start = $date->startOfWeek($startOfWeek->carbonWeekDay());
|
||||
for ($i = 0; $i < 7; $i++) {
|
||||
|
||||
@@ -86,7 +86,13 @@ class DeletionService
|
||||
'currentOrganization',
|
||||
])
|
||||
->get();
|
||||
$organization->users()->sync([]);
|
||||
|
||||
$members = Member::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->get();
|
||||
foreach ($members as $member) {
|
||||
$member->delete();
|
||||
}
|
||||
|
||||
// Make sure all users have at least one organization and delete placeholders
|
||||
foreach ($users as $user) {
|
||||
@@ -139,7 +145,7 @@ class DeletionService
|
||||
|
||||
foreach ($members as $member) {
|
||||
if ($member->role === Role::Owner->value && $member->organization->users()->count() > 1) {
|
||||
throw new CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers();
|
||||
throw new CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
12
app/Service/Export/ExportException.php
Normal file
12
app/Service/Export/ExportException.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Export;
|
||||
|
||||
use App\Exceptions\Api\ApiException;
|
||||
|
||||
class ExportException extends ApiException
|
||||
{
|
||||
public const string KEY = 'export';
|
||||
}
|
||||
362
app/Service/Export/ExportService.php
Normal file
362
app/Service/Export/ExportService.php
Normal file
@@ -0,0 +1,362 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Export;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\File;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use League\Csv\CannotInsertRecord;
|
||||
use League\Csv\Exception as LeagueCsvException;
|
||||
use League\Csv\UnavailableStream;
|
||||
use League\Csv\Writer;
|
||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||
use ZipArchive;
|
||||
|
||||
class ExportService
|
||||
{
|
||||
public const string VERSION = '1.0';
|
||||
|
||||
/**
|
||||
* @throws ExportException
|
||||
*/
|
||||
public function export(Organization $organization): string
|
||||
{
|
||||
$exportId = Str::uuid();
|
||||
$timeStamp = Carbon::now();
|
||||
$temporaryDirectory = TemporaryDirectory::make();
|
||||
Log::debug('Start exporting organization', [
|
||||
'organization_id' => $organization->getKey(),
|
||||
'export_id' => $exportId,
|
||||
]);
|
||||
|
||||
// Organizations
|
||||
try {
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('organizations.csv'), 'w+');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'name',
|
||||
'billable_rate',
|
||||
'currency',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
$writer->insertOne([
|
||||
$organization->id,
|
||||
$organization->name,
|
||||
$organization->billable_rate ?? '',
|
||||
$organization->currency,
|
||||
$organization->created_at?->toIso8601ZuluString() ?? '',
|
||||
$organization->updated_at?->toIso8601ZuluString() ?? '',
|
||||
]);
|
||||
|
||||
// Organization invitations
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('organization_invitations.csv'), 'w+');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'email',
|
||||
'organization_id',
|
||||
'role',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
OrganizationInvitation::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->chunk(1000, function (Collection $organizationInvitations) use (&$writer): void {
|
||||
$organizationInvitations->each(function (OrganizationInvitation $organizationInvitation) use (&$writer): void {
|
||||
$writer->insertOne([
|
||||
$organizationInvitation->id,
|
||||
$organizationInvitation->email,
|
||||
$organizationInvitation->organization_id,
|
||||
$organizationInvitation->role,
|
||||
$organizationInvitation->created_at?->toIso8601ZuluString() ?? '',
|
||||
$organizationInvitation->updated_at?->toIso8601ZuluString() ?? '',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Time entries
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('time_entries.csv'), 'w+');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'description',
|
||||
'start',
|
||||
'end',
|
||||
'billable_rate',
|
||||
'billable',
|
||||
'member_id',
|
||||
'user_id',
|
||||
'organization_id',
|
||||
'client_id',
|
||||
'project_id',
|
||||
'task_id',
|
||||
'tags',
|
||||
'is_imported',
|
||||
'still_active_email_sent_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
TimeEntry::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->chunk(1000, function (Collection $timeEntries) use (&$writer): void {
|
||||
$timeEntries->each(function (TimeEntry $timeEntry) use (&$writer): void {
|
||||
$tags = json_encode($timeEntry->tags);
|
||||
$writer->insertOne([
|
||||
$timeEntry->id,
|
||||
$timeEntry->description,
|
||||
$timeEntry->start->toIso8601ZuluString(),
|
||||
$timeEntry->end?->toIso8601ZuluString() ?? '',
|
||||
$timeEntry->billable_rate ?? '',
|
||||
$timeEntry->billable ? 'true' : 'false',
|
||||
$timeEntry->member_id,
|
||||
$timeEntry->user_id,
|
||||
$timeEntry->organization_id,
|
||||
$timeEntry->client_id ?? '',
|
||||
$timeEntry->project_id ?? '',
|
||||
$timeEntry->task_id ?? '',
|
||||
$tags === false ? '' : $tags,
|
||||
$timeEntry->is_imported ? 'true' : 'false',
|
||||
$timeEntry->still_active_email_sent_at?->toIso8601ZuluString() ?? '',
|
||||
$timeEntry->created_at?->toIso8601ZuluString() ?? '',
|
||||
$timeEntry->updated_at?->toIso8601ZuluString() ?? '',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Clients
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('clients.csv'), 'w+');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'name',
|
||||
'organization_id',
|
||||
'archived_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
Client::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->chunk(1000, function (Collection $clients) use (&$writer): void {
|
||||
$clients->each(function (Client $client) use (&$writer): void {
|
||||
$writer->insertOne([
|
||||
$client->id,
|
||||
$client->name,
|
||||
$client->organization_id,
|
||||
$client->archived_at ?? '',
|
||||
$client->created_at?->toIso8601ZuluString() ?? '',
|
||||
$client->updated_at?->toIso8601ZuluString() ?? '',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Projects
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('projects.csv'), 'w+');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'name',
|
||||
'color',
|
||||
'billable_rate',
|
||||
'is_public',
|
||||
'client_id',
|
||||
'organization_id',
|
||||
'is_billable',
|
||||
'archived_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
Project::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->chunk(1000, function (Collection $projects) use (&$writer): void {
|
||||
$projects->each(function (Project $project) use (&$writer): void {
|
||||
$writer->insertOne([
|
||||
$project->id,
|
||||
$project->name,
|
||||
$project->color,
|
||||
$project->billable_rate ?? '',
|
||||
$project->is_public ? 'true' : 'false',
|
||||
$project->client_id ?? '',
|
||||
$project->organization_id,
|
||||
$project->is_billable ? 'true' : 'false',
|
||||
$project->archived_at?->toIso8601ZuluString() ?? '',
|
||||
$project->created_at?->toIso8601ZuluString() ?? '',
|
||||
$project->updated_at?->toIso8601ZuluString() ?? '',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Project members
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('project_members.csv'), 'w+');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'billable_rate',
|
||||
'project_id',
|
||||
'user_id',
|
||||
'member_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
ProjectMember::query()
|
||||
->whereBelongsToOrganization($organization)
|
||||
->chunk(1000, function (Collection $projectMembers) use (&$writer): void {
|
||||
$projectMembers->each(function (ProjectMember $projectMember) use (&$writer): void {
|
||||
$writer->insertOne([
|
||||
$projectMember->id,
|
||||
$projectMember->billable_rate ?? '',
|
||||
$projectMember->project_id,
|
||||
$projectMember->user_id,
|
||||
$projectMember->member_id,
|
||||
$projectMember->created_at?->toIso8601ZuluString() ?? '',
|
||||
$projectMember->updated_at?->toIso8601ZuluString() ?? '',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Members
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('members.csv'), 'w+');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'user_id',
|
||||
'name',
|
||||
'email',
|
||||
'organization_id',
|
||||
'billable_rate',
|
||||
'role',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
Member::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->with([
|
||||
'user',
|
||||
])
|
||||
->chunk(1000, function (Collection $members) use (&$writer): void {
|
||||
$members->each(function (Member $member) use (&$writer): void {
|
||||
$writer->insertOne([
|
||||
$member->id,
|
||||
$member->user_id,
|
||||
$member->user->name,
|
||||
$member->user->email,
|
||||
$member->organization_id,
|
||||
$member->billable_rate ?? '',
|
||||
$member->role,
|
||||
$member->created_at?->toIso8601ZuluString() ?? '',
|
||||
$member->updated_at?->toIso8601ZuluString() ?? '',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Tasks
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('tasks.csv'), 'w+');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'name',
|
||||
'project_id',
|
||||
'organization_id',
|
||||
'done_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
Task::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->chunk(1000, function (Collection $tasks) use (&$writer): void {
|
||||
$tasks->each(function (Task $task) use (&$writer): void {
|
||||
$writer->insertOne([
|
||||
$task->id,
|
||||
$task->name,
|
||||
$task->project_id,
|
||||
$task->organization_id,
|
||||
$task->done_at?->toIso8601ZuluString() ?? '',
|
||||
$task->created_at?->toIso8601ZuluString() ?? '',
|
||||
$task->updated_at?->toIso8601ZuluString() ?? '',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Tags
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('tags.csv'), 'w+');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'name',
|
||||
'organization_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
Tag::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->chunk(1000, function (Collection $tags) use (&$writer): void {
|
||||
$tags->each(function (Tag $tag) use (&$writer): void {
|
||||
$writer->insertOne([
|
||||
$tag->id,
|
||||
$tag->name,
|
||||
$tag->organization_id,
|
||||
$tag->created_at?->toIso8601ZuluString() ?? '',
|
||||
$tag->updated_at?->toIso8601ZuluString() ?? '',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Meta data file
|
||||
$metaData = (object) [
|
||||
'id' => $exportId,
|
||||
'version' => self::VERSION,
|
||||
'organizations' => [$organization->getKey()],
|
||||
'exported_at' => $timeStamp->toIso8601ZuluString(),
|
||||
];
|
||||
file_put_contents($temporaryDirectory->path('meta.json'), json_encode($metaData));
|
||||
|
||||
// Create ZIP file
|
||||
$temporaryDirectoryZip = TemporaryDirectory::make();
|
||||
$zip = new ZipArchive;
|
||||
if ($zip->open($temporaryDirectoryZip->path('export.zip'), ZipArchive::CREATE) !== true) {
|
||||
throw new Exception('Cannot create ZIP file');
|
||||
}
|
||||
$zip->addFile($temporaryDirectory->path('organizations.csv'), 'organizations.csv');
|
||||
$zip->addFile($temporaryDirectory->path('organization_invitations.csv'), 'organization_invitations.csv');
|
||||
$zip->addFile($temporaryDirectory->path('time_entries.csv'), 'time_entries.csv');
|
||||
$zip->addFile($temporaryDirectory->path('clients.csv'), 'clients.csv');
|
||||
$zip->addFile($temporaryDirectory->path('projects.csv'), 'projects.csv');
|
||||
$zip->addFile($temporaryDirectory->path('project_members.csv'), 'project_members.csv');
|
||||
$zip->addFile($temporaryDirectory->path('members.csv'), 'members.csv');
|
||||
$zip->addFile($temporaryDirectory->path('tasks.csv'), 'tasks.csv');
|
||||
$zip->addFile($temporaryDirectory->path('tags.csv'), 'tags.csv');
|
||||
$zip->addFile($temporaryDirectory->path('meta.json'), 'meta.json');
|
||||
$zip->close();
|
||||
|
||||
// Upload ZIP file to private storage
|
||||
$filename = 'export_'.$organization->getKey().'_'.$timeStamp->format('Y-m-d_H-i-s').'_'.$exportId.'.zip';
|
||||
Storage::disk(config('filesystems.private'))->putFileAs(
|
||||
'exports',
|
||||
new File($temporaryDirectoryZip->path('export.zip')),
|
||||
$filename
|
||||
);
|
||||
|
||||
// Delete temp files
|
||||
$temporaryDirectoryZip->delete();
|
||||
$temporaryDirectory->delete();
|
||||
|
||||
Log::debug('Finished exporting organization', [
|
||||
'organization_id' => $organization->getKey(),
|
||||
'export_id' => $exportId,
|
||||
]);
|
||||
|
||||
return 'exports/'.$filename;
|
||||
} catch (UnavailableStream|CannotInsertRecord|Exception|LeagueCsvException $exception) {
|
||||
report($exception);
|
||||
|
||||
throw new ExportException;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,13 +98,17 @@ class ImportDatabaseHelper
|
||||
throw new ImportException('Invalid data: '.implode(', ', $validator->errors()->all()));
|
||||
}
|
||||
|
||||
$model = new $this->model();
|
||||
/** @var TModel $model */
|
||||
$model = new $this->model;
|
||||
foreach ($data as $key => $value) {
|
||||
$model->{$key} = $value;
|
||||
}
|
||||
if ($this->beforeSave !== null) {
|
||||
($this->beforeSave)($model);
|
||||
}
|
||||
if (method_exists($model, 'disableAuditing')) {
|
||||
$model->disableAuditing();
|
||||
}
|
||||
$model->save();
|
||||
|
||||
if ($this->afterCreate !== null) {
|
||||
@@ -240,7 +244,7 @@ class ImportDatabaseHelper
|
||||
{
|
||||
if ($this->mapIdentifierToKey === null) {
|
||||
$select = $this->identifiers;
|
||||
$select[] = (new $this->model())->getKeyName();
|
||||
$select[] = (new $this->model)->getKeyName();
|
||||
$builder = $this->getModelInstance();
|
||||
|
||||
if ($this->queryModifier !== null) {
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Service\Import\Importers\ImporterProvider;
|
||||
use App\Service\Import\Importers\ImportException;
|
||||
use App\Service\Import\Importers\ReportDto;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -27,9 +28,16 @@ class ImportService
|
||||
Storage::disk(config('filesystems.default'))
|
||||
->put('import/'.Carbon::now()->toDateString().'-'.$organization->getKey().'-'.Str::uuid(), $data);
|
||||
|
||||
DB::transaction(function () use (&$importer, &$data, &$timezone) {
|
||||
$importer->importData($data, $timezone);
|
||||
});
|
||||
$lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1);
|
||||
|
||||
if ($lock->get()) {
|
||||
DB::transaction(function () use (&$importer, &$data, &$timezone) {
|
||||
$importer->importData($data, $timezone);
|
||||
});
|
||||
$lock->release();
|
||||
} else {
|
||||
throw new ImportException('Import is already in progress');
|
||||
}
|
||||
|
||||
return $importer->getReport();
|
||||
}
|
||||
|
||||
@@ -98,7 +98,8 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
'organization_id' => $this->organization->id,
|
||||
]);
|
||||
}
|
||||
$timeEntry = new TimeEntry();
|
||||
$timeEntry = new TimeEntry;
|
||||
$timeEntry->disableAuditing();
|
||||
$timeEntry->user_id = $userId;
|
||||
$timeEntry->member_id = $memberId;
|
||||
$timeEntry->task_id = $taskId;
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Service\Import\Importers;
|
||||
use App\Models\Client;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\Tag;
|
||||
@@ -63,6 +64,11 @@ abstract class DefaultImporter implements ImporterContract
|
||||
*/
|
||||
protected ImportDatabaseHelper $projectMemberImportHelper;
|
||||
|
||||
/**
|
||||
* @var ImportDatabaseHelper<OrganizationInvitation>
|
||||
*/
|
||||
protected ImportDatabaseHelper $organizationInvitationsImportHelper;
|
||||
|
||||
protected BillableRateService $billableRateService;
|
||||
|
||||
public function init(Organization $organization): void
|
||||
@@ -112,7 +118,7 @@ abstract class DefaultImporter implements ImporterContract
|
||||
$project->billable_rate = null;
|
||||
}
|
||||
});
|
||||
$this->projectMemberImportHelper = new ImportDatabaseHelper(ProjectMember::class, ['project_id', 'member_id'], true, function (Builder $builder) {
|
||||
$this->projectMemberImportHelper = new ImportDatabaseHelper(ProjectMember::class, ['project_id', 'member_id'], true, function (Builder $builder): Builder {
|
||||
/** @var Builder<ProjectMember> $builder */
|
||||
return $builder->whereBelongsToOrganization($this->organization);
|
||||
}, validate: [
|
||||
@@ -125,7 +131,8 @@ abstract class DefaultImporter implements ImporterContract
|
||||
$projectMember->billable_rate = null;
|
||||
}
|
||||
});
|
||||
$this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder) {
|
||||
$this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder): Builder {
|
||||
/** @var Builder<Tag> $builder */
|
||||
return $builder->where('organization_id', $this->organization->id);
|
||||
}, validate: [
|
||||
'name' => [
|
||||
@@ -133,7 +140,8 @@ abstract class DefaultImporter implements ImporterContract
|
||||
'max:255',
|
||||
],
|
||||
]);
|
||||
$this->clientImportHelper = new ImportDatabaseHelper(Client::class, ['name', 'organization_id'], true, function (Builder $builder) {
|
||||
$this->clientImportHelper = new ImportDatabaseHelper(Client::class, ['name', 'organization_id'], true, function (Builder $builder): Builder {
|
||||
/** @var Builder<Client> $builder */
|
||||
return $builder->where('organization_id', $this->organization->id);
|
||||
}, validate: [
|
||||
'name' => [
|
||||
@@ -141,7 +149,8 @@ abstract class DefaultImporter implements ImporterContract
|
||||
'max:255',
|
||||
],
|
||||
]);
|
||||
$this->taskImportHelper = new ImportDatabaseHelper(Task::class, ['name', 'project_id', 'organization_id'], true, function (Builder $builder) {
|
||||
$this->taskImportHelper = new ImportDatabaseHelper(Task::class, ['name', 'project_id', 'organization_id'], true, function (Builder $builder): Builder {
|
||||
/** @var Builder<Task> $builder */
|
||||
return $builder->where('organization_id', $this->organization->id);
|
||||
}, validate: [
|
||||
'name' => [
|
||||
@@ -149,6 +158,16 @@ abstract class DefaultImporter implements ImporterContract
|
||||
'max:500',
|
||||
],
|
||||
]);
|
||||
$this->organizationInvitationsImportHelper = new ImportDatabaseHelper(OrganizationInvitation::class, ['email', 'organization_id'], true, function (Builder $builder) {
|
||||
/** @var Builder<OrganizationInvitation> $builder */
|
||||
return $builder->where('organization_id', $this->organization->id);
|
||||
}, validate: [
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
'max:255',
|
||||
],
|
||||
]);
|
||||
$this->timeEntriesCreated = 0;
|
||||
$this->colorService = app(ColorService::class);
|
||||
$this->timezoneService = app(TimezoneService::class);
|
||||
|
||||
@@ -4,6 +4,4 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
class ImportException extends \Exception
|
||||
{
|
||||
}
|
||||
class ImportException extends \Exception {}
|
||||
|
||||
@@ -14,6 +14,7 @@ class ImporterProvider
|
||||
'toggl_data_importer' => TogglDataImporter::class,
|
||||
'clockify_time_entries' => ClockifyTimeEntriesImporter::class,
|
||||
'clockify_projects' => ClockifyProjectsImporter::class,
|
||||
'solidtime' => SolidtimeImporter::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
336
app/Service/Import/Importers/SolidtimeImporter.php
Normal file
336
app/Service/Import/Importers/SolidtimeImporter.php
Normal file
@@ -0,0 +1,336 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\TimeEntry;
|
||||
use Carbon\Exceptions\InvalidFormatException;
|
||||
use Exception;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use League\Csv\Reader;
|
||||
use Override;
|
||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||
use ZipArchive;
|
||||
|
||||
class SolidtimeImporter extends DefaultImporter
|
||||
{
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
public const array SUPPORTED_VERSIONS = ['1.0'];
|
||||
|
||||
/**
|
||||
* @throws ImportException
|
||||
*/
|
||||
#[Override]
|
||||
public function importData(string $data, string $timezone): void
|
||||
{
|
||||
$temporaryDirectoryZip = null;
|
||||
$temporaryDirectory = null;
|
||||
try {
|
||||
$zip = new ZipArchive;
|
||||
$temporaryDirectoryZip = TemporaryDirectory::make();
|
||||
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
|
||||
$res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY);
|
||||
if ($res !== true) {
|
||||
throw new ImportException('Invalid ZIP, error code: '.$res);
|
||||
}
|
||||
$temporaryDirectory = TemporaryDirectory::make();
|
||||
$zip->extractTo($temporaryDirectory->path());
|
||||
$zip->close();
|
||||
|
||||
if (! file_exists($temporaryDirectory->path('meta.json'))) {
|
||||
throw new ImportException('File "meta.json" missing in ZIP');
|
||||
}
|
||||
$metaFileContentRaw = file_get_contents($temporaryDirectory->path('meta.json'));
|
||||
if ($metaFileContentRaw === false) {
|
||||
throw new ImportException('File "meta.json" can not read');
|
||||
}
|
||||
$metaFileContent = json_decode($metaFileContentRaw);
|
||||
if ($metaFileContent === false || ! isset($metaFileContent->version) || ! in_array($metaFileContent->version, self::SUPPORTED_VERSIONS, true)) {
|
||||
throw new ImportException('Invalid version');
|
||||
}
|
||||
|
||||
if (! file_exists($temporaryDirectory->path('clients.csv'))) {
|
||||
throw new ImportException('File "clients.csv" missing in ZIP');
|
||||
}
|
||||
$clientsReader = Reader::createFromPath($temporaryDirectory->path('clients.csv'));
|
||||
$clientsReader->setHeaderOffset(0);
|
||||
$clientsReader->setDelimiter(',');
|
||||
|
||||
if (! file_exists($temporaryDirectory->path('members.csv'))) {
|
||||
throw new ImportException('File "members.csv" missing in ZIP');
|
||||
}
|
||||
$membersReader = Reader::createFromPath($temporaryDirectory->path('members.csv'));
|
||||
$membersReader->setHeaderOffset(0);
|
||||
$membersReader->setDelimiter(',');
|
||||
|
||||
if (! file_exists($temporaryDirectory->path('organization_invitations.csv'))) {
|
||||
throw new ImportException('File "organization_invitations.csv" missing in ZIP');
|
||||
}
|
||||
$organizationInvitationsReader = Reader::createFromPath($temporaryDirectory->path('organization_invitations.csv'));
|
||||
$organizationInvitationsReader->setHeaderOffset(0);
|
||||
$organizationInvitationsReader->setDelimiter(',');
|
||||
|
||||
if (! file_exists($temporaryDirectory->path('project_members.csv'))) {
|
||||
throw new ImportException('File "project_members.csv" missing in ZIP');
|
||||
}
|
||||
$projectMembersReader = Reader::createFromPath($temporaryDirectory->path('project_members.csv'));
|
||||
$projectMembersReader->setHeaderOffset(0);
|
||||
$projectMembersReader->setDelimiter(',');
|
||||
|
||||
if (! file_exists($temporaryDirectory->path('projects.csv'))) {
|
||||
throw new ImportException('File "projects.csv" missing in ZIP');
|
||||
}
|
||||
$projectsReader = Reader::createFromPath($temporaryDirectory->path('projects.csv'));
|
||||
$projectsReader->setHeaderOffset(0);
|
||||
$projectsReader->setDelimiter(',');
|
||||
|
||||
if (! file_exists($temporaryDirectory->path('tags.csv'))) {
|
||||
throw new ImportException('File "tags.csv" missing in ZIP');
|
||||
}
|
||||
$tagsReader = Reader::createFromPath($temporaryDirectory->path('tags.csv'));
|
||||
$tagsReader->setHeaderOffset(0);
|
||||
$tagsReader->setDelimiter(',');
|
||||
|
||||
if (! file_exists($temporaryDirectory->path('tasks.csv'))) {
|
||||
throw new ImportException('File "tasks.csv" missing in ZIP');
|
||||
}
|
||||
$tasksReader = Reader::createFromPath($temporaryDirectory->path('tasks.csv'));
|
||||
$tasksReader->setHeaderOffset(0);
|
||||
$tasksReader->setDelimiter(',');
|
||||
|
||||
if (! file_exists($temporaryDirectory->path('time_entries.csv'))) {
|
||||
throw new ImportException('File "time_entries.csv" missing in ZIP');
|
||||
}
|
||||
$timeEntriesReader = Reader::createFromPath($temporaryDirectory->path('time_entries.csv'));
|
||||
$timeEntriesReader->setHeaderOffset(0);
|
||||
$timeEntriesReader->setDelimiter(',');
|
||||
|
||||
foreach ($clientsReader as $client) {
|
||||
$this->clientImportHelper->getKey([
|
||||
'name' => $client['name'],
|
||||
'organization_id' => $this->organization->id,
|
||||
], [
|
||||
'archived_at' => $client['archived_at'] !== '' ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $client['archived_at'], 'UTC') : null,
|
||||
], $client['id']);
|
||||
}
|
||||
|
||||
foreach ($tagsReader as $tag) {
|
||||
$this->tagImportHelper->getKey([
|
||||
'name' => $tag['name'],
|
||||
'organization_id' => $this->organization->id,
|
||||
], [], $tag['id']);
|
||||
}
|
||||
|
||||
foreach ($membersReader as $member) {
|
||||
$userId = $this->userImportHelper->getKey([
|
||||
'email' => $member['email'],
|
||||
], [
|
||||
'name' => $member['name'],
|
||||
'timezone' => 'UTC',
|
||||
'is_placeholder' => true,
|
||||
], $member['user_id']);
|
||||
$this->memberImportHelper->getKey([
|
||||
'user_id' => $userId,
|
||||
'organization_id' => $this->organization->getKey(),
|
||||
], [
|
||||
'role' => Role::Placeholder->value,
|
||||
'billable_rate' => $member['billable_rate'] === '' ? null : (int) $member['billable_rate'],
|
||||
], $member['id']);
|
||||
}
|
||||
|
||||
foreach ($projectsReader as $project) {
|
||||
$clientId = null;
|
||||
if ($project['client_id'] !== '') {
|
||||
$clientId = $this->clientImportHelper->getKeyByExternalIdentifier($project['client_id']);
|
||||
if ($clientId === null) {
|
||||
throw new Exception('Client does not exist');
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->colorService->isValid($project['color'])) {
|
||||
throw new ImportException('Invalid color');
|
||||
}
|
||||
|
||||
$this->projectImportHelper->getKey([
|
||||
'name' => $project['name'],
|
||||
'organization_id' => $this->organization->getKey(),
|
||||
], [
|
||||
'color' => $project['color'],
|
||||
'billable_rate' => $project['billable_rate'] === '' ? null : (int) $project['billable_rate'],
|
||||
'is_public' => $project['is_public'] === 'true',
|
||||
'client_id' => $clientId,
|
||||
'is_billable' => $project['is_billable'] === 'true',
|
||||
'archived_at' => $project['archived_at'] !== '' ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $project['archived_at'], 'UTC') : null,
|
||||
], $project['id']);
|
||||
}
|
||||
|
||||
foreach ($projectMembersReader as $projectMember) {
|
||||
$userId = $this->userImportHelper->getKeyByExternalIdentifier($projectMember['user_id']);
|
||||
$memberId = $this->memberImportHelper->getKeyByExternalIdentifier($projectMember['member_id']);
|
||||
$projectId = $this->projectImportHelper->getKeyByExternalIdentifier($projectMember['project_id']);
|
||||
$this->projectMemberImportHelper->getKey([
|
||||
'project_id' => $projectId,
|
||||
'member_id' => $memberId,
|
||||
], [
|
||||
'user_id' => $userId,
|
||||
'billable_rate' => $projectMember['billable_rate'] === '' ? null : (int) $projectMember['billable_rate'],
|
||||
], $projectMember['id']);
|
||||
}
|
||||
|
||||
foreach ($tasksReader as $task) {
|
||||
$projectId = $this->projectImportHelper->getKeyByExternalIdentifier($task['project_id']);
|
||||
if ($projectId === null) {
|
||||
throw new Exception('Project does not exist');
|
||||
}
|
||||
$this->taskImportHelper->getKey([
|
||||
'name' => $task['name'],
|
||||
'project_id' => $projectId,
|
||||
'organization_id' => $this->organization->getKey(),
|
||||
], [
|
||||
'done_at' => $task['done_at'] !== '' ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $task['done_at'], 'UTC') : null,
|
||||
], (string) $task['id']);
|
||||
}
|
||||
|
||||
// Time entries
|
||||
foreach ($timeEntriesReader as $timeEntryRow) {
|
||||
$userId = $this->userImportHelper->getKeyByExternalIdentifier($timeEntryRow['user_id']);
|
||||
$memberId = $this->memberImportHelper->getKeyByExternalIdentifier($timeEntryRow['member_id']);
|
||||
$member = $this->memberImportHelper->getModelById($memberId);
|
||||
$clientId = null;
|
||||
if ($timeEntryRow['client_id'] !== '') {
|
||||
$clientId = $this->clientImportHelper->getKeyByExternalIdentifier($timeEntryRow['client_id']);
|
||||
}
|
||||
$project = null;
|
||||
$projectId = null;
|
||||
$projectMember = null;
|
||||
if ($timeEntryRow['project_id'] !== '') {
|
||||
$projectId = $this->projectImportHelper->getKeyByExternalIdentifier($timeEntryRow['project_id']);
|
||||
$project = $this->projectImportHelper->getModelById($projectId);
|
||||
$projectMember = $this->projectMemberImportHelper->getModel([
|
||||
'project_id' => $projectId,
|
||||
'member_id' => $memberId,
|
||||
]);
|
||||
}
|
||||
$taskId = null;
|
||||
if ($timeEntryRow['task_id'] !== '') {
|
||||
$taskId = $this->taskImportHelper->getKeyByExternalIdentifier($timeEntryRow['task_id']);
|
||||
}
|
||||
$timeEntry = new TimeEntry;
|
||||
$timeEntry->disableAuditing();
|
||||
$timeEntry->user_id = $userId;
|
||||
$timeEntry->member_id = $memberId;
|
||||
$timeEntry->task_id = $taskId;
|
||||
$timeEntry->project_id = $projectId;
|
||||
$timeEntry->client_id = $clientId;
|
||||
$timeEntry->organization_id = $this->organization->id;
|
||||
if (strlen($timeEntryRow['description']) > 500) {
|
||||
throw new ImportException('Time entry description is too long');
|
||||
}
|
||||
$timeEntry->description = $timeEntryRow['description'];
|
||||
if (! in_array($timeEntryRow['billable'], ['true', 'false'], true)) {
|
||||
throw new ImportException('Invalid billable value');
|
||||
}
|
||||
$timeEntry->billable = $timeEntryRow['billable'] === 'true';
|
||||
$timeEntry->tags = $this->getTags($timeEntryRow['tags']);
|
||||
$timeEntry->is_imported = true;
|
||||
|
||||
try {
|
||||
$start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['start'], 'UTC');
|
||||
} catch (InvalidFormatException) {
|
||||
throw new ImportException('Start date ("'.$timeEntryRow['start'].'") is invalid');
|
||||
}
|
||||
if ($start === null) {
|
||||
throw new ImportException('Start date ("'.$timeEntryRow['start'].'") is invalid');
|
||||
}
|
||||
$timeEntry->start = $start->utc();
|
||||
|
||||
if ($timeEntryRow['end'] !== '') {
|
||||
try {
|
||||
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['end'], 'UTC');
|
||||
} catch (InvalidFormatException) {
|
||||
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
|
||||
}
|
||||
if ($end === null) {
|
||||
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
|
||||
}
|
||||
$timeEntry->end = $end->utc();
|
||||
} else {
|
||||
$timeEntry->end = null;
|
||||
}
|
||||
|
||||
if ($timeEntryRow['still_active_email_sent_at'] !== '') {
|
||||
try {
|
||||
$stillActiveEmailSentAt = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['still_active_email_sent_at'], 'UTC');
|
||||
} catch (InvalidFormatException) {
|
||||
throw new ImportException('Still active email timestamp ("'.$timeEntryRow['still_active_email_sent_at'].'") is invalid');
|
||||
}
|
||||
if ($stillActiveEmailSentAt === null) {
|
||||
throw new ImportException('Still active email timestamp ("'.$timeEntryRow['still_active_email_sent_at'].'") is invalid');
|
||||
}
|
||||
$timeEntry->still_active_email_sent_at = $stillActiveEmailSentAt->utc();
|
||||
} else {
|
||||
$timeEntry->still_active_email_sent_at = null;
|
||||
}
|
||||
|
||||
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
|
||||
$timeEntry,
|
||||
$projectMember,
|
||||
$project,
|
||||
$member,
|
||||
$this->organization
|
||||
);
|
||||
$timeEntry->save();
|
||||
$this->timeEntriesCreated++;
|
||||
}
|
||||
} catch (ImportException $exception) {
|
||||
throw $exception;
|
||||
} catch (Exception $exception) {
|
||||
report($exception);
|
||||
throw new ImportException('Unknown error');
|
||||
} finally {
|
||||
$temporaryDirectory?->delete();
|
||||
$temporaryDirectoryZip?->delete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
private function getTags(string $tags): array
|
||||
{
|
||||
if (trim($tags) === '') {
|
||||
return [];
|
||||
}
|
||||
$tagsParsed = json_decode($tags);
|
||||
if ($tagsParsed === false || ! is_array($tagsParsed)) {
|
||||
return [];
|
||||
}
|
||||
$tagIds = [];
|
||||
foreach ($tagsParsed as $tagParsed) {
|
||||
if (! is_string($tagParsed) || ! Str::isUuid($tagParsed)) {
|
||||
continue;
|
||||
}
|
||||
$tagId = $this->tagImportHelper->getKeyByExternalIdentifier($tagParsed);
|
||||
$tagIds[] = $tagId;
|
||||
}
|
||||
|
||||
return $tagIds;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function getName(): string
|
||||
{
|
||||
return __('importer.solidtime_importer.name');
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function getDescription(): string
|
||||
{
|
||||
return __('importer.solidtime_importer.description');
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ namespace App\Service\Import\Importers;
|
||||
|
||||
use App\Enums\Role;
|
||||
use Exception;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Override;
|
||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||
use ValueError;
|
||||
use ZipArchive;
|
||||
@@ -15,14 +17,16 @@ class TogglDataImporter extends DefaultImporter
|
||||
/**
|
||||
* @throws ImportException
|
||||
*/
|
||||
#[\Override]
|
||||
#[Override]
|
||||
public function importData(string $data, string $timezone): void
|
||||
{
|
||||
$temporaryDirectoryZip = null;
|
||||
$temporaryDirectory = null;
|
||||
try {
|
||||
$zip = new ZipArchive();
|
||||
$temporaryDirectory = TemporaryDirectory::make();
|
||||
file_put_contents($temporaryDirectory->path('import.zip'), $data);
|
||||
$res = $zip->open($temporaryDirectory->path('import.zip'), ZipArchive::RDONLY);
|
||||
$zip = new ZipArchive;
|
||||
$temporaryDirectoryZip = TemporaryDirectory::make();
|
||||
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
|
||||
$res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY);
|
||||
if ($res !== true) {
|
||||
throw new ImportException('Invalid ZIP, error code: '.$res);
|
||||
}
|
||||
@@ -77,7 +81,9 @@ class TogglDataImporter extends DefaultImporter
|
||||
$this->clientImportHelper->getKey([
|
||||
'name' => $client->name,
|
||||
'organization_id' => $this->organization->id,
|
||||
], [], (string) $client->id);
|
||||
], [
|
||||
'archived_at' => $client->archived === true ? Carbon::now() : null,
|
||||
], (string) $client->id);
|
||||
}
|
||||
foreach ($tags as $tag) {
|
||||
$this->tagImportHelper->getKey([
|
||||
@@ -121,7 +127,8 @@ class TogglDataImporter extends DefaultImporter
|
||||
], [
|
||||
'client_id' => $clientId,
|
||||
'color' => $project->color,
|
||||
'is_billable' => $project->rate !== null,
|
||||
'is_billable' => $project->billable,
|
||||
'is_public' => ! $project->is_private,
|
||||
'billable_rate' => $project->rate !== null ? (int) ($project->rate * 100) : null,
|
||||
], (string) $project->id);
|
||||
|
||||
@@ -170,7 +177,9 @@ class TogglDataImporter extends DefaultImporter
|
||||
'name' => $task->name,
|
||||
'project_id' => $projectId,
|
||||
'organization_id' => $this->organization->getKey(),
|
||||
], [], (string) $task->id);
|
||||
], [
|
||||
'done_at' => $task->active === false ? Carbon::now() : null,
|
||||
], (string) $task->id);
|
||||
}
|
||||
}
|
||||
} catch (ValueError $exception) {
|
||||
@@ -180,16 +189,19 @@ class TogglDataImporter extends DefaultImporter
|
||||
} catch (Exception $exception) {
|
||||
report($exception);
|
||||
throw new ImportException('Unknown error');
|
||||
} finally {
|
||||
$temporaryDirectory?->delete();
|
||||
$temporaryDirectoryZip?->delete();
|
||||
}
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
#[Override]
|
||||
public function getName(): string
|
||||
{
|
||||
return __('importer.toggl_data_importer.name');
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
#[Override]
|
||||
public function getDescription(): string
|
||||
{
|
||||
return __('importer.toggl_data_importer.description');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user