Skip to content

Commit 5fca5a3

Browse files
committed
wip
2 parents 9ae9bcc + 19a206d commit 5fca5a3

File tree

51 files changed

+4282
-1725
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+4282
-1725
lines changed
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
on:
2+
push:
3+
branches:
4+
- main
5+
- develop
6+
tags:
7+
- '*'
8+
pull_request:
9+
paths:
10+
- '.github/workflows/build-onpremise.yml'
11+
- 'docker/prod/**'
12+
workflow_dispatch:
13+
14+
permissions:
15+
packages: write
16+
contents: read
17+
attestations: write
18+
id-token: write
19+
20+
env:
21+
DOCKER_REPO: registry.on-premise.solidtime.io/solidtime/solidtime
22+
23+
name: Build - On Premise
24+
jobs:
25+
build:
26+
strategy:
27+
matrix:
28+
include:
29+
- runs-on: "ubuntu-24.04-arm"
30+
platform: "linux/arm64"
31+
- runs-on: "ubuntu-24.04"
32+
platform: "linux/amd64"
33+
runs-on: ${{ matrix.runs-on }}
34+
timeout-minutes: 90
35+
36+
steps:
37+
- name: "Check out code"
38+
uses: actions/checkout@v4
39+
with:
40+
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
41+
42+
- name: "Get build"
43+
id: release-build
44+
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
45+
46+
- name: "Get Previous tag (normal push)"
47+
id: previoustag
48+
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
49+
uses: "WyriHaximus/github-action-get-previous-tag@v1"
50+
with:
51+
prefix: "v"
52+
53+
- name: "Get version"
54+
id: release-version
55+
run: |
56+
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
57+
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
58+
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
59+
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
60+
else
61+
echo "ERROR: No previous tag found";
62+
exit 1;
63+
fi
64+
else
65+
version=$(echo "${{ github.ref }}" | cut -c 12-)
66+
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
67+
fi
68+
69+
- name: "Copy .env template for production"
70+
run: |
71+
cp .env.production .env
72+
rm .env.production .env.ci .env.example
73+
74+
- name: "Add version to .env"
75+
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.release-version.outputs.app_version }}/g' .env
76+
77+
- name: "Add build to .env"
78+
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.release-build.outputs.build }}/g' .env
79+
80+
- name: "Output .env"
81+
run: cat .env
82+
83+
- name: "Setup PHP with PECL extension"
84+
uses: shivammathur/setup-php@v2
85+
with:
86+
php-version: '8.3'
87+
extensions: mbstring, dom, fileinfo, pgsql
88+
89+
- name: "Install dependencies"
90+
run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
91+
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
92+
93+
- name: "Use Node.js"
94+
uses: actions/setup-node@v4
95+
with:
96+
node-version: '20.x'
97+
98+
- name: "Checkout invoicing extension"
99+
uses: actions/checkout@v4
100+
with:
101+
repository: solidtime-io/extension-invoicing
102+
path: extensions/Invoicing
103+
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
104+
105+
- name: "Install composer dependencies in invoicing extension"
106+
run: cd extensions/Invoicing && composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
107+
108+
- name: "Install npm dependencies in invoicing extension"
109+
run: cd extensions/Invoicing && npm ci
110+
111+
- name: "Activate invoicing extension"
112+
run: php artisan module:enable Invoicing
113+
114+
- name: "Install npm dependencies"
115+
run: npm ci
116+
117+
- name: "Build"
118+
run: npm run build
119+
120+
- name: "Prepare"
121+
run: |
122+
platform=${{ matrix.platform }}
123+
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
124+
125+
- name: "Docker meta"
126+
id: "meta"
127+
uses: docker/metadata-action@v5
128+
with:
129+
images: |
130+
${{ env.DOCKER_REPO }}
131+
132+
- name: "Login to solidtime OnPremise Registry"
133+
uses: docker/login-action@v3
134+
with:
135+
registry: registry.on-premise.solidtime.io
136+
username: ${{ secrets.ONPREMISE_USERNAME }}
137+
password: ${{ secrets.ONPREMISE_TOKEN }}
138+
139+
- name: "Set up QEMU"
140+
uses: docker/setup-qemu-action@v3
141+
142+
- name: "Set up Docker Buildx"
143+
uses: docker/setup-buildx-action@v3
144+
145+
- name: "Build and push by digest"
146+
id: build
147+
uses: docker/build-push-action@v6
148+
with:
149+
context: .
150+
file: docker/prod/Dockerfile
151+
build-args: |
152+
DOCKER_FILES_BASE_PATH=docker/prod/
153+
platforms: ${{ matrix.platform }}
154+
labels: ${{ steps.meta.outputs.labels }}
155+
outputs: type=image,"name=${{ env.DOCKER_REPO }}",push-by-digest=true,name-canonical=true,push=true
156+
cache-from: type=gha
157+
cache-to: type=gha,mode=max
158+
159+
- name: "Export digest"
160+
run: |
161+
mkdir -p ${{ runner.temp }}/digests
162+
digest="${{ steps.build.outputs.digest }}"
163+
touch "${{ runner.temp }}/digests/${digest#sha256:}"
164+
165+
- name: "Upload digest"
166+
uses: actions/upload-artifact@v4
167+
with:
168+
name: digests-${{ env.PLATFORM_PAIR }}
169+
path: ${{ runner.temp }}/digests/*
170+
if-no-files-found: error
171+
retention-days: 1
172+
173+
merge:
174+
runs-on: ubuntu-latest
175+
timeout-minutes: 90
176+
needs:
177+
- build
178+
steps:
179+
- name: "Download digests"
180+
uses: actions/download-artifact@v4
181+
with:
182+
path: ${{ runner.temp }}/digests
183+
pattern: digests-*
184+
merge-multiple: true
185+
186+
- name: "Login to solidtime OnPremise Registry"
187+
uses: docker/login-action@v3
188+
with:
189+
registry: registry.on-premise.solidtime.io
190+
username: ${{ secrets.ONPREMISE_USERNAME }}
191+
password: ${{ secrets.ONPREMISE_TOKEN }}
192+
193+
- name: "Set up Docker Buildx"
194+
uses: docker/setup-buildx-action@v3
195+
196+
- name: "Docker meta"
197+
id: meta
198+
uses: docker/metadata-action@v5
199+
with:
200+
images: |
201+
${{ env.DOCKER_REPO }}
202+
tags: |
203+
type=ref,event=branch
204+
type=ref,event=pr
205+
type=semver,pattern={{version}}
206+
type=semver,pattern={{major}}.{{minor}}
207+
208+
- name: "Create manifest list and push"
209+
working-directory: ${{ runner.temp }}/digests
210+
run: |
211+
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
212+
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
213+
214+
- name: "Inspect image"
215+
run: |
216+
docker buildx imagetools inspect ${{ env.DOCKER_REPO }}:${{ steps.meta.outputs.version }}

app/Enums/TimeEntryAggregationType.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ enum TimeEntryAggregationType: string
2020
case Client = 'client';
2121
case Billable = 'billable';
2222
case Description = 'description';
23+
case Tag = 'tag';
2324

2425
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
2526
{
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Exceptions\Api;
6+
7+
class OverlappingTimeEntryApiException extends ApiException
8+
{
9+
public const string KEY = 'overlapping_time_entry';
10+
}

app/Http/Controllers/Api/V1/OrganizationController.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ public function update(Organization $organization, OrganizationUpdateRequest $re
6161
if ($request->getTimeFormat() !== null) {
6262
$organization->time_format = $request->getTimeFormat();
6363
}
64+
if ($request->getPreventOverlappingTimeEntries() !== null) {
65+
$organization->prevent_overlapping_time_entries = $request->getPreventOverlappingTimeEntries();
66+
}
6467
$hasBillableRate = $request->has('billable_rate');
6568
if ($hasBillableRate) {
6669
$oldBillableRate = $organization->billable_rate;

app/Http/Controllers/Api/V1/TimeEntryController.php

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use App\Enums\ExportFormat;
88
use App\Enums\Role;
99
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
10+
use App\Exceptions\Api\OverlappingTimeEntryApiException;
1011
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
1112
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
1213
use App\Exceptions\Api\TimeEntryStillRunningApiException;
@@ -45,6 +46,7 @@
4546
use Illuminate\Http\File;
4647
use Illuminate\Http\JsonResponse;
4748
use Illuminate\Http\Resources\Json\JsonResource;
49+
use Illuminate\Support\Carbon;
4850
use Illuminate\Support\Collection;
4951
use Illuminate\Support\Facades\Auth;
5052
use Illuminate\Support\Facades\Blade;
@@ -56,6 +58,43 @@
5658

5759
class TimeEntryController extends Controller
5860
{
61+
private function assertNoOverlap(Organization $organization, Member $member, \Illuminate\Support\Carbon $start, ?\Illuminate\Support\Carbon $end, ?TimeEntry $exclude = null): void
62+
{
63+
if (! $organization->prevent_overlapping_time_entries) {
64+
return;
65+
}
66+
67+
$query = TimeEntry::query()
68+
->where('organization_id', $organization->getKey())
69+
->where('user_id', $member->user_id)
70+
->when($exclude !== null, function (Builder $q) use ($exclude): void {
71+
$q->where('id', '!=', $exclude->getKey());
72+
})
73+
->where(function (Builder $q) use ($start, $end): void {
74+
$q->where(function (Builder $q2) use ($start): void {
75+
$q2->where('end', '>', $start)
76+
->where('start', '<', $start);
77+
});
78+
79+
if ($end !== null) {
80+
$q->orWhere(function (Builder $q4) use ($end): void {
81+
$q4->where('start', '<', $end)
82+
->where('end', '>', $end);
83+
});
84+
// Check if the new entry completely surrounds an existing entry
85+
$q->orWhere(function (Builder $q6) use ($start, $end): void {
86+
$q6->where('start', '>=', $start)
87+
->where('end', '<=', $end);
88+
});
89+
}
90+
91+
});
92+
93+
if ($query->exists()) {
94+
throw new OverlappingTimeEntryApiException;
95+
}
96+
}
97+
5998
protected function checkPermission(Organization $organization, string $permission, ?TimeEntry $timeEntry = null): void
6099
{
61100
parent::checkPermission($organization, $permission);
@@ -549,17 +588,15 @@ public function store(Organization $organization, TimeEntryStoreRequest $request
549588
throw new TimeEntryStillRunningApiException;
550589
}
551590

591+
// Overlap check for create
592+
$start = Carbon::parse($request->input('start'));
593+
$end = $request->input('end') !== null ? Carbon::parse($request->input('end')) : null;
594+
$this->assertNoOverlap($organization, $member, $start, $end);
595+
552596
$project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;
553597
$client = $project?->client;
554598
$task = $request->input('task_id') !== null ? $project->tasks()->findOrFail((string) $request->input('task_id')) : null;
555599

556-
if ($project !== null) {
557-
RecalculateSpentTimeForProject::dispatch($project);
558-
}
559-
if ($task !== null) {
560-
RecalculateSpentTimeForTask::dispatch($task);
561-
}
562-
563600
$timeEntry = new TimeEntry;
564601
$timeEntry->fill($request->validated());
565602
$timeEntry->client()->associate($client);
@@ -569,6 +606,13 @@ public function store(Organization $organization, TimeEntryStoreRequest $request
569606
$timeEntry->setComputedAttributeValue('billable_rate');
570607
$timeEntry->save();
571608

609+
if ($project !== null) {
610+
RecalculateSpentTimeForProject::dispatch($project);
611+
}
612+
if ($task !== null) {
613+
RecalculateSpentTimeForTask::dispatch($task);
614+
}
615+
572616
return new TimeEntryResource($timeEntry);
573617
}
574618

@@ -593,6 +637,13 @@ public function update(Organization $organization, TimeEntry $timeEntry, TimeEnt
593637
throw new TimeEntryCanNotBeRestartedApiException;
594638
}
595639

640+
// Overlap check for update (exclude current)
641+
/** @var Member $effectiveMember */
642+
$effectiveMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : $timeEntry->member;
643+
$effectiveStart = $request->has('start') ? Carbon::parse($request->input('start')) : $timeEntry->start;
644+
$effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end;
645+
$this->assertNoOverlap($organization, $effectiveMember, $effectiveStart, $effectiveEnd, $timeEntry);
646+
596647
$oldProject = $timeEntry->project;
597648
$oldTask = $timeEntry->task;
598649

app/Http/Middleware/HandleInertiaRequests.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ public function share(Request $request): array
4141
{
4242
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
4343
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
44+
$hasServices = Module::has('Services') && Module::isEnabled('Services');
4445

4546
/** @var BillingContract $billing */
4647
$billing = app(BillingContract::class);
@@ -50,6 +51,7 @@ public function share(Request $request): array
5051
return array_merge(parent::share($request), [
5152
'has_billing_extension' => $hasBilling,
5253
'has_invoicing_extension' => $hasInvoicing,
54+
'has_services_extension' => $hasServices,
5355
'billing' => $currentOrganization !== null ? [
5456
'has_subscription' => $billing->hasSubscription($currentOrganization),
5557
'has_trial' => $billing->hasTrial($currentOrganization),

app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ public function rules(): array
3939
'employees_can_see_billable_rates' => [
4040
'boolean',
4141
],
42+
'prevent_overlapping_time_entries' => [
43+
'boolean',
44+
],
4245
'number_format' => [
4346
Rule::enum(NumberFormat::class),
4447
],
@@ -98,4 +101,9 @@ public function getEmployeesCanSeeBillableRates(): ?bool
98101
{
99102
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
100103
}
104+
105+
public function getPreventOverlappingTimeEntries(): ?bool
106+
{
107+
return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null;
108+
}
101109
}

0 commit comments

Comments
 (0)