mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 08:12:17 +01:00
Compare commits
17 Commits
feature/br
...
613d243760
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
613d243760 | ||
|
|
8f6d584ee9 | ||
|
|
1905cbf40c | ||
|
|
c8f668238e | ||
|
|
bf11bacdee | ||
|
|
f3c6a0b8ae | ||
|
|
619c602571 | ||
|
|
600daf44d9 | ||
|
|
a1d6c92806 | ||
|
|
ce1bd6a435 | ||
|
|
79d85227c2 | ||
|
|
885abf3333 | ||
|
|
c0c8fee6be | ||
|
|
cbcd1e51f6 | ||
|
|
114a32536d | ||
|
|
ff8a0f065b | ||
|
|
44fd0ffb91 |
@@ -1,5 +1,7 @@
|
||||
.git
|
||||
**/.git
|
||||
.gitmodules
|
||||
**/.gitmodules
|
||||
.github
|
||||
.DS_Store
|
||||
.fleet
|
||||
@@ -8,6 +10,13 @@
|
||||
*.log
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
k8s
|
||||
docs
|
||||
e2e
|
||||
tests
|
||||
|
||||
docker-compose.yml
|
||||
docker/local
|
||||
|
||||
.phpunit.cache
|
||||
.phpunit.result.cache
|
||||
@@ -16,6 +25,18 @@ test-results
|
||||
playwright-report
|
||||
blob-report
|
||||
playwright/.cache
|
||||
openapi.json
|
||||
playwright
|
||||
playwright.config.ts
|
||||
vitest.config.ts
|
||||
phpunit.xml
|
||||
phpstan.neon
|
||||
pint.json
|
||||
eslint.config.mjs
|
||||
tsconfig.json
|
||||
jsconfig.json
|
||||
postcss.config.js
|
||||
tailwind.config.js
|
||||
|
||||
node_modules
|
||||
extensions/*/node_modules
|
||||
@@ -30,3 +51,4 @@ _ide_helper.php
|
||||
.phpstorm.meta.php
|
||||
|
||||
storage/logs/*
|
||||
storage/*.key
|
||||
|
||||
25
.github/VOUCHED.td
vendored
Normal file
25
.github/VOUCHED.td
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Vouched contributors for solidtime.
|
||||
#
|
||||
# One handle per line, without the leading @, sorted alphabetically.
|
||||
# Prefix a handle with - to denounce them, optionally followed by a reason.
|
||||
# Format reference: https://github.com/mitchellh/vouch
|
||||
#
|
||||
# Maintainers do not need to edit this file by hand. Comment "vouch @user",
|
||||
# "unvouch @user" or "denounce @user <reason>" on any issue, pull request or
|
||||
# discussion and the vouch workflows will update this file.
|
||||
#
|
||||
# Collaborators with write access and bots are always allowed and do not need
|
||||
# an entry here.
|
||||
#
|
||||
# Seeded 2026-07-25 from the authors of every merged pull request.
|
||||
|
||||
agross
|
||||
bufferhead-code
|
||||
candideu
|
||||
kasparrosin
|
||||
korridor
|
||||
onatcer
|
||||
shrootbuck
|
||||
smilebeda
|
||||
thespyder
|
||||
utlark
|
||||
1
.github/workflows/build-onpremise.yml
vendored
1
.github/workflows/build-onpremise.yml
vendored
@@ -8,6 +8,7 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build-onpremise.yml'
|
||||
- '.dockerignore'
|
||||
- 'extensions/manifest.json'
|
||||
- 'docker/prod/**'
|
||||
workflow_dispatch:
|
||||
|
||||
1
.github/workflows/build-private.yml
vendored
1
.github/workflows/build-private.yml
vendored
@@ -8,6 +8,7 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build-private.yml'
|
||||
- '.dockerignore'
|
||||
- 'extensions/manifest.json'
|
||||
- 'docker/prod/**'
|
||||
workflow_dispatch:
|
||||
|
||||
1
.github/workflows/build-public.yml
vendored
1
.github/workflows/build-public.yml
vendored
@@ -8,6 +8,7 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build-public.yml'
|
||||
- '.dockerignore'
|
||||
- 'docker/prod/**'
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
75
.github/workflows/vouch-check-pr.yml
vendored
Normal file
75
.github/workflows/vouch-check-pr.yml
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
name: Vouch (check PR)
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
if: >-
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '/recheck'))
|
||||
|
||||
steps:
|
||||
# Pull requests of 50 changed lines or fewer skip the vouch requirement.
|
||||
- name: "Measure diff size"
|
||||
id: size
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
|
||||
# Changes to these files do not count towards the 50-line limit.
|
||||
# One extended regex per line, matched against the whole repo-relative
|
||||
# path, so use a leading .* to match a file in any directory.
|
||||
IGNORED: |
|
||||
package-lock\.json
|
||||
composer\.lock
|
||||
tests/.*
|
||||
e2e/.*
|
||||
.*\.(test|spec)\.(ts|js|vue)
|
||||
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# An empty list yields "^()$", which matches no filename. grep exits
|
||||
# 1 on an empty list, so swallow that rather than fail the step.
|
||||
join() { { grep -vE '^[[:space:]]*$' || true; } | paste -sd'|' -; }
|
||||
ignored="^($(join <<<"$IGNORED"))$"
|
||||
|
||||
total=$(gh api --paginate "repos/$REPO/pulls/$PR/files" \
|
||||
--jq '.[] | [.filename, .additions + .deletions] | @tsv' |
|
||||
awk -F'\t' -v ignored="$ignored" '
|
||||
$1 ~ ignored { next }
|
||||
{ n += $2 }
|
||||
END { print n+0 }')
|
||||
echo "total=$total" >> "$GITHUB_OUTPUT"
|
||||
echo "Countable diff size: $total line(s)"
|
||||
|
||||
- name: "Small patch (denounced users still blocked)"
|
||||
if: fromJSON(steps.size.outputs.total) <= 50
|
||||
uses: mitchellh/vouch/action/check-pr@v1.5.0
|
||||
with:
|
||||
pr-number: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
auto-close: true
|
||||
require-vouch: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: "Full vouch required"
|
||||
if: fromJSON(steps.size.outputs.total) > 50
|
||||
uses: mitchellh/vouch/action/check-pr@v1.5.0
|
||||
with:
|
||||
pr-number: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
auto-close: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
33
.github/workflows/vouch-manage-by-discussion.yml
vendored
Normal file
33
.github/workflows/vouch-manage-by-discussion.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: Vouch (manage by discussion)
|
||||
|
||||
# Same commands as vouch-manage-by-issue.yml, but for discussion comments.
|
||||
|
||||
on:
|
||||
discussion_comment:
|
||||
types: [created]
|
||||
|
||||
concurrency:
|
||||
group: vouch-manage
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
discussions: write
|
||||
|
||||
jobs:
|
||||
manage:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: "Apply vouch command"
|
||||
uses: mitchellh/vouch/action/manage-by-discussion@v1.5.0
|
||||
with:
|
||||
discussion-number: ${{ github.event.discussion.number }}
|
||||
comment-node-id: ${{ github.event.comment.node_id }}
|
||||
roles: admin,maintain,write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
35
.github/workflows/vouch-manage-by-issue.yml
vendored
Normal file
35
.github/workflows/vouch-manage-by-issue.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
name: Vouch (manage by issue)
|
||||
|
||||
# Maintainers comment "vouch @user", "unvouch @user" or "denounce @user <reason>"
|
||||
# on any issue or pull request, and this workflow updates .github/VOUCHED.td.
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
concurrency:
|
||||
group: vouch-manage
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
manage:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: "Apply vouch command"
|
||||
uses: mitchellh/vouch/action/manage-by-issue@v1.5.0
|
||||
with:
|
||||
issue-id: ${{ github.event.issue.number }}
|
||||
comment-id: ${{ github.event.comment.id }}
|
||||
roles: admin,maintain,write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -12,6 +12,22 @@ In order to keep the issues of the repository clean we decided to only use them
|
||||
|
||||
To respect your time and help us manage contributions effectively, please open an issue or start a discussion and wait for approval before submitting a pull request (PR). This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
|
||||
|
||||
### Vouched contributors
|
||||
|
||||
Pull requests from authors who are not vouched are closed automatically. This lets us keep up with the volume of AI slop pull requests without a maintainer having to triage every one of them by hand.
|
||||
|
||||
Your pull request is not affected if any of the following applies:
|
||||
|
||||
- You have write access to this repository.
|
||||
- Someone with write access has vouched for you. The list lives in [.github/VOUCHED.td](.github/VOUCHED.td).
|
||||
- Your pull request changes 50 lines or fewer. Test files and lockfiles do not count towards that number, so a small fix that comes with tests still qualifies.
|
||||
|
||||
To get vouched, open an issue or discussion before you start and explain how you intend to implement the change. We will discuss the approach with you, and only once we have agreed on the implementation does a maintainer comment `vouch @your-handle`, which puts you on the list from then on.
|
||||
|
||||
Being vouched only stops your pull requests from being closed automatically. [Only work on approved issues](#only-work-on-approved-issues) still applies to every pull request you send.
|
||||
|
||||
Contributors who abuse this are denounced, and their pull requests are closed regardless of size.
|
||||
|
||||
### Contributor License Agreement
|
||||
|
||||
You'll also notice that we’ve set up a [Contributor License Agreement (CLA)](https://cla-assistant.io/solidtime-io/solidtime), which must be signed before any PR can be merged. Don’t worry - the process is quick and only takes a few clicks.
|
||||
|
||||
@@ -39,6 +39,8 @@ Please open an issue or start a discussion and wait for approval before submitti
|
||||
|
||||
**If you submit an AI slop pull request (especially without following the proper procedure), you will be banned from future contributions to solidtime.**
|
||||
|
||||
To keep that manageable, pull requests from authors who are not vouched are closed automatically, unless they change 50 lines or fewer. To get vouched, open an issue or discussion first and explain how you intend to implement the change. Once we have agreed on the approach, we vouch for you. See [Vouched contributors](./CONTRIBUTING.md#vouched-contributors).
|
||||
|
||||
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.
|
||||
|
||||
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
|
||||
|
||||
@@ -68,7 +68,7 @@ class TimeEntryController extends Controller
|
||||
|
||||
$query = TimeEntry::query()
|
||||
->where('organization_id', $organization->getKey())
|
||||
->where('user_id', $member->user_id)
|
||||
->where('member_id', $member->getKey())
|
||||
->when($exclude !== null, function (Builder $q) use ($exclude): void {
|
||||
$q->where('id', '!=', $exclude->getKey());
|
||||
})
|
||||
@@ -108,8 +108,8 @@ class TimeEntryController extends Controller
|
||||
/**
|
||||
* Get time entries in organization
|
||||
*
|
||||
* If you only need time entries for a specific user, you can filter by `user_id`.
|
||||
* Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter.
|
||||
* If you only need time entries for a specific user, you can filter by `member_id`.
|
||||
* Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter.
|
||||
*
|
||||
* @return TimeEntryCollection<TimeEntryResource>
|
||||
*
|
||||
@@ -119,16 +119,17 @@ class TimeEntryController extends Controller
|
||||
*/
|
||||
public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource
|
||||
{
|
||||
/** @var Member|null $member */
|
||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($member !== null && $member->user_id === Auth::id()) {
|
||||
$member = $this->member($organization);
|
||||
/** @var Member|null $memberFilter */
|
||||
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
|
||||
$this->checkPermission($organization, 'time-entries:view:own');
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:view:all');
|
||||
}
|
||||
|
||||
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
|
||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures);
|
||||
|
||||
$totalCount = $timeEntriesQuery->count();
|
||||
|
||||
@@ -159,7 +160,7 @@ class TimeEntryController extends Controller
|
||||
if ($timeEntries->count() === 0) {
|
||||
Log::warning('User has has more than '.$limit.' time entries on one date', [
|
||||
'date' => $lastDate->toDateString(),
|
||||
'user_id' => $request->input('user_id'),
|
||||
'member_id' => $request->input('member_id'),
|
||||
'auth_user_id' => Auth::id(),
|
||||
'limit' => $limit,
|
||||
]);
|
||||
@@ -223,9 +224,10 @@ class TimeEntryController extends Controller
|
||||
*/
|
||||
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
||||
{
|
||||
/** @var Member|null $member */
|
||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($member !== null && $member->user_id === Auth::id()) {
|
||||
$member = $this->member($organization);
|
||||
/** @var Member|null $memberFilter */
|
||||
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
|
||||
$this->checkPermission($organization, 'time-entries:view:own');
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:view:all');
|
||||
@@ -242,7 +244,7 @@ class TimeEntryController extends Controller
|
||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||
|
||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
|
||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures);
|
||||
$timeEntriesQuery->with([
|
||||
'task',
|
||||
'client',
|
||||
@@ -265,7 +267,7 @@ class TimeEntryController extends Controller
|
||||
if ($viewFile === false) {
|
||||
throw new \LogicException('View file not found');
|
||||
}
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
|
||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||
$timeEntriesAggregateQuery,
|
||||
null,
|
||||
@@ -372,9 +374,10 @@ class TimeEntryController extends Controller
|
||||
*/
|
||||
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array
|
||||
{
|
||||
/** @var Member|null $member */
|
||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($member !== null && $member->user_id === Auth::id()) {
|
||||
$member = $this->member($organization);
|
||||
/** @var Member|null $memberFilter */
|
||||
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
|
||||
$this->checkPermission($organization, 'time-entries:view:own');
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:view:all');
|
||||
@@ -385,7 +388,7 @@ class TimeEntryController extends Controller
|
||||
|
||||
$group1Type = $request->getGroup();
|
||||
$group2Type = $request->getSubGroup();
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
|
||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||
|
||||
@@ -421,9 +424,10 @@ class TimeEntryController extends Controller
|
||||
*/
|
||||
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
||||
{
|
||||
/** @var Member|null $member */
|
||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($member !== null && $member->user_id === Auth::id()) {
|
||||
$member = $this->member($organization);
|
||||
/** @var Member|null $memberFilter */
|
||||
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
|
||||
$this->checkPermission($organization, 'time-entries:view:own');
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:view:all');
|
||||
@@ -439,7 +443,7 @@ class TimeEntryController extends Controller
|
||||
|
||||
$group = $request->getGroup();
|
||||
$subGroup = $request->getSubGroup();
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
|
||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||
|
||||
@@ -583,7 +587,7 @@ class TimeEntryController extends Controller
|
||||
{
|
||||
/** @var Member $member */
|
||||
$member = Member::query()->findOrFail($request->input('member_id'));
|
||||
if ($member->user_id === Auth::id()) {
|
||||
if ($member->getKey() === $this->member($organization)->getKey()) {
|
||||
$this->checkPermission($organization, 'time-entries:create:own');
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:create:all');
|
||||
@@ -630,9 +634,10 @@ class TimeEntryController extends Controller
|
||||
*/
|
||||
public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource
|
||||
{
|
||||
/** @var Member|null $member */
|
||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) {
|
||||
$member = $this->member($organization);
|
||||
/** @var Member|null $newMember */
|
||||
$newMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($timeEntry->member_id === $member->getKey() && ($newMember === null || $newMember->getKey() === $member->getKey())) {
|
||||
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
|
||||
@@ -664,6 +669,10 @@ class TimeEntryController extends Controller
|
||||
}
|
||||
|
||||
$timeEntry->fill($request->validated());
|
||||
if ($newMember !== null) {
|
||||
$timeEntry->member()->associate($newMember);
|
||||
$timeEntry->user()->associate($newMember->user);
|
||||
}
|
||||
$timeEntry->description = $request->input('description', $timeEntry->description) ?? '';
|
||||
$timeEntry->setComputedAttributeValue('billable_rate');
|
||||
$timeEntry->save();
|
||||
@@ -693,6 +702,7 @@ class TimeEntryController extends Controller
|
||||
*/
|
||||
public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse
|
||||
{
|
||||
$member = $this->member($organization);
|
||||
$this->checkAnyPermission($organization, ['time-entries:update:all', 'time-entries:update:own']);
|
||||
$canAccessAll = $this->hasPermission($organization, 'time-entries:update:all');
|
||||
|
||||
@@ -717,6 +727,9 @@ class TimeEntryController extends Controller
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
/** @var Member|null $newMember */
|
||||
$newMember = isset($changes['member_id']) ? Member::query()->findOrFail($changes['member_id']) : null;
|
||||
|
||||
$project = null;
|
||||
$client = null;
|
||||
$overwriteClient = false;
|
||||
@@ -743,7 +756,7 @@ class TimeEntryController extends Controller
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $canAccessAll && $timeEntry->user_id !== Auth::id()) {
|
||||
if (! $canAccessAll && $timeEntry->member_id !== $member->getKey()) {
|
||||
$error->push($id);
|
||||
|
||||
continue;
|
||||
@@ -766,6 +779,10 @@ class TimeEntryController extends Controller
|
||||
$oldTask = $timeEntry->task;
|
||||
|
||||
$timeEntry->fill($changes);
|
||||
if ($newMember !== null) {
|
||||
$timeEntry->member()->associate($newMember);
|
||||
$timeEntry->user_id = $newMember->user_id;
|
||||
}
|
||||
// If project is changed, but task is not, we remove the old task from the time entry
|
||||
if ($oldProject !== null && $project !== null && $oldProject->isNot($project) && $task === null) {
|
||||
$timeEntry->task()->disassociate();
|
||||
@@ -806,7 +823,8 @@ class TimeEntryController extends Controller
|
||||
*/
|
||||
public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse
|
||||
{
|
||||
if ($timeEntry->member->user_id === Auth::id()) {
|
||||
$member = $this->member($organization);
|
||||
if ($timeEntry->member_id === $member->getKey()) {
|
||||
$this->checkPermission($organization, 'time-entries:delete:own', $timeEntry);
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);
|
||||
@@ -863,7 +881,7 @@ class TimeEntryController extends Controller
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $canDeleteAll && $timeEntry->user_id !== Auth::id()) {
|
||||
if (! $canDeleteAll && $timeEntry->member_id !== $this->member($organization)->getKey()) {
|
||||
$error->push($id);
|
||||
|
||||
continue;
|
||||
|
||||
@@ -23,6 +23,10 @@ class InvitationService
|
||||
*/
|
||||
public function inviteUser(Organization $organization, string $email, Role $role, User $inviter): OrganizationInvitation
|
||||
{
|
||||
// Normalize the email so it matches how user emails are stored (see UserService::createUser),
|
||||
// otherwise a mixed-case invite silently fails to link on registration.
|
||||
$email = strtolower($email);
|
||||
|
||||
if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) {
|
||||
throw new UserIsAlreadyMemberOfOrganizationApiException;
|
||||
}
|
||||
@@ -55,7 +59,7 @@ class InvitationService
|
||||
$organizations = new Collection;
|
||||
|
||||
$invitations = OrganizationInvitation::query()
|
||||
->where('email', $user->email)
|
||||
->whereRaw('lower(email) = ?', [strtolower($user->email)])
|
||||
->whereNotNull('accepted_at')
|
||||
->get();
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
"ext-zip": "*",
|
||||
"brick/money": "^0.10.0",
|
||||
"datomatic/laravel-enum-helper": "^2.0.0",
|
||||
"dedoc/scramble": "^0.12.2",
|
||||
"dedoc/scramble": "^0.13.36",
|
||||
"filament/filament": "^3.2",
|
||||
"flowframe/laravel-trend": "^0.4.0",
|
||||
"flowframe/laravel-trend": "^0.5.0",
|
||||
"gotenberg/gotenberg-php": "^2.8",
|
||||
"guzzlehttp/guzzle": "^7.2",
|
||||
"inertiajs/inertia-laravel": "^2.0.3",
|
||||
|
||||
920
composer.lock
generated
920
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -189,7 +189,9 @@ ENV WITH_HORIZON=false \
|
||||
WITH_SCHEDULER=false \
|
||||
WITH_REVERB=false
|
||||
|
||||
COPY --link --chown=${WWWUSER}:${WWWUSER} . .
|
||||
COPY --link --chown=${WWWUSER}:${WWWUSER} . ./
|
||||
|
||||
RUN test -z "$(find . -name .git -print -quit)"
|
||||
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
|
||||
|
||||
RUN mkdir -p \
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"Billing": {
|
||||
"repository": "solidtime-io/extension-billing",
|
||||
"ref": "v0.0.1"
|
||||
"ref": "v0.0.3"
|
||||
},
|
||||
"Services": {
|
||||
"repository": "solidtime-io/extension-services",
|
||||
@@ -9,6 +9,6 @@
|
||||
},
|
||||
"Invoicing": {
|
||||
"repository": "solidtime-io/extension-invoicing",
|
||||
"ref": "v0.0.1"
|
||||
"ref": "v0.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,6 +380,40 @@ class RegistrationTest extends TestCaseWithDatabase
|
||||
$this->assertSame($user->organization->id, $organizations->first()->id);
|
||||
}
|
||||
|
||||
public function test_registration_joins_invited_organization_even_if_invitation_email_casing_differs(): void
|
||||
{
|
||||
// Arrange: invitation stored with a different casing than the registration email
|
||||
$user = $this->createUserWithPermission();
|
||||
OrganizationInvitation::factory()
|
||||
->forOrganization($user->organization)
|
||||
->role(Role::Employee)
|
||||
->accepted()
|
||||
->create([
|
||||
'email' => 'Invited.User@example.com',
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = $this->post('/register', [
|
||||
'name' => 'Invited User',
|
||||
'email' => 'invited.user@example.com',
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
'terms' => true,
|
||||
]);
|
||||
|
||||
// Assert: joined the inviting organization, no extra personal organization, invitation consumed
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(RouteServiceProvider::HOME);
|
||||
$newUser = User::where('email', 'invited.user@example.com')->first();
|
||||
$this->assertNotNull($newUser);
|
||||
$this->assertDatabaseMissing(OrganizationInvitation::class, [
|
||||
'email' => 'Invited.User@example.com',
|
||||
]);
|
||||
$organizations = $newUser->organizations;
|
||||
$this->assertCount(1, $organizations);
|
||||
$this->assertSame($user->organization->id, $organizations->first()->id);
|
||||
}
|
||||
|
||||
public function test_registration_logs_and_skips_accepted_invitation_with_invalid_role(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -92,6 +92,30 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
$response->assertJsonPath('data.0.id', $timeEntry->getKey());
|
||||
}
|
||||
|
||||
public function test_index_endpoint_filters_by_member_id_instead_of_legacy_user_id(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:view:own',
|
||||
]);
|
||||
$legacyUser = User::factory()->create();
|
||||
$timeEntry = TimeEntry::factory()->forMember($data->member)->create([
|
||||
'user_id' => $legacyUser->getKey(),
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.time-entries.index', [
|
||||
$data->organization->getKey(),
|
||||
'member_id' => $data->member->getKey(),
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 200);
|
||||
$response->assertJsonCount(1, 'data');
|
||||
$response->assertJsonPath('data.0.id', $timeEntry->getKey());
|
||||
}
|
||||
|
||||
public function test_index_endpoint_fails_if_user_filter_is_from_different_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -126,7 +150,10 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.time-entries.index', [$data->organization->getKey(), 'user_id' => $user->getKey()]));
|
||||
$response = $this->getJson(route('api.v1.time-entries.index', [
|
||||
$data->organization->getKey(),
|
||||
'member_id' => $member->getKey(),
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 200);
|
||||
@@ -1772,6 +1799,29 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_aggregate_endpoint_filters_by_member_id_instead_of_legacy_user_id(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:view:own',
|
||||
]);
|
||||
$legacyUser = User::factory()->create();
|
||||
TimeEntry::factory()->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create([
|
||||
'user_id' => $legacyUser->getKey(),
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.time-entries.aggregate', [
|
||||
$data->organization->getKey(),
|
||||
'member_id' => $data->member->getKey(),
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonPath('data.seconds', 100);
|
||||
}
|
||||
|
||||
public function test_aggregate_endpoint_groups_by_two_groups(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -2819,6 +2869,32 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_update_endpoint_updates_user_id_when_member_id_changes(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:update:all',
|
||||
]);
|
||||
$otherUser = User::factory()->create();
|
||||
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();
|
||||
$timeEntry = TimeEntry::factory()->forMember($data->member)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
|
||||
'member_id' => $otherMember->getKey(),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertValid();
|
||||
$this->assertResponseCode($response, 200);
|
||||
$this->assertDatabaseHas(TimeEntry::class, [
|
||||
'id' => $timeEntry->getKey(),
|
||||
'member_id' => $otherMember->getKey(),
|
||||
'user_id' => $otherUser->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_update_endpoint_can_update_project_and_automatically_set_client(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -3155,6 +3231,40 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_destroy_multiple_uses_member_id_for_own_permission_checks(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:delete:own',
|
||||
]);
|
||||
$otherUser = User::factory()->create();
|
||||
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();
|
||||
$timeEntry = TimeEntry::factory()->forMember($otherMember)->create([
|
||||
'user_id' => $data->user->getKey(),
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.time-entries.destroy-multiple', [$data->organization->getKey()]), [
|
||||
'ids' => [
|
||||
$timeEntry->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertValid();
|
||||
$this->assertResponseCode($response, 200);
|
||||
$response->assertExactJson([
|
||||
'success' => [],
|
||||
'error' => [
|
||||
$timeEntry->getKey(),
|
||||
],
|
||||
]);
|
||||
$this->assertDatabaseHas(TimeEntry::class, [
|
||||
'id' => $timeEntry->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_destroy_multiple_deletes_all_time_entries_and_fails_for_time_entries_of_other_users_and_and_other_organizations_with_all_time_entries_permission(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -3566,6 +3676,46 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_update_multiple_uses_member_id_for_own_permission_checks(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:update:own',
|
||||
'projects:view:all',
|
||||
]);
|
||||
$otherUser = User::factory()->create();
|
||||
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();
|
||||
$timeEntry = TimeEntry::factory()->forMember($otherMember)->create([
|
||||
'user_id' => $data->user->getKey(),
|
||||
]);
|
||||
$timeEntriesFake = TimeEntry::factory()->forOrganization($data->organization)->make();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [
|
||||
'ids' => [
|
||||
$timeEntry->getKey(),
|
||||
],
|
||||
'changes' => [
|
||||
'description' => $timeEntriesFake->description,
|
||||
],
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertValid();
|
||||
$this->assertResponseCode($response, 200);
|
||||
$response->assertExactJson([
|
||||
'success' => [],
|
||||
'error' => [
|
||||
$timeEntry->getKey(),
|
||||
],
|
||||
]);
|
||||
$this->assertDatabaseHas(TimeEntry::class, [
|
||||
'id' => $timeEntry->getKey(),
|
||||
'description' => $timeEntry->description,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_update_multiple_updates_sets_description_to_empty_if_the_client_sends_null(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -3612,6 +3762,51 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_update_multiple_updates_user_id_when_member_id_changes(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:update:all',
|
||||
]);
|
||||
$otherUser = User::factory()->create();
|
||||
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();
|
||||
$timeEntry1 = TimeEntry::factory()->forMember($data->member)->create();
|
||||
$timeEntry2 = TimeEntry::factory()->forMember($data->member)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [
|
||||
'ids' => [
|
||||
$timeEntry1->getKey(),
|
||||
$timeEntry2->getKey(),
|
||||
],
|
||||
'changes' => [
|
||||
'member_id' => $otherMember->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertValid();
|
||||
$response->assertStatus(200);
|
||||
$response->assertExactJson([
|
||||
'success' => [
|
||||
$timeEntry1->getKey(),
|
||||
$timeEntry2->getKey(),
|
||||
],
|
||||
'error' => [],
|
||||
]);
|
||||
$this->assertDatabaseHas(TimeEntry::class, [
|
||||
'id' => $timeEntry1->getKey(),
|
||||
'member_id' => $otherMember->getKey(),
|
||||
'user_id' => $otherUser->getKey(),
|
||||
]);
|
||||
$this->assertDatabaseHas(TimeEntry::class, [
|
||||
'id' => $timeEntry2->getKey(),
|
||||
'member_id' => $otherMember->getKey(),
|
||||
'user_id' => $otherUser->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_update_multiple_updates_all_time_entries_and_fails_for_time_entries_of_other_users_and_and_other_organizations_with_all_time_entries_permission(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
Reference in New Issue
Block a user