mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-09 14:23:19 +01:00
Compare commits
4 Commits
feature/er
...
extended-g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c540be236 | ||
|
|
3bddd49364 | ||
|
|
ab5dbfef36 | ||
|
|
2eb9ae699c |
1
.github/VOUCHED.td
vendored
1
.github/VOUCHED.td
vendored
@@ -18,7 +18,6 @@ bufferhead-code
|
|||||||
candideu
|
candideu
|
||||||
kasparrosin
|
kasparrosin
|
||||||
korridor
|
korridor
|
||||||
nikbucher tasks table sorting
|
|
||||||
onatcer
|
onatcer
|
||||||
shrootbuck
|
shrootbuck
|
||||||
smilebeda
|
smilebeda
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ class RouteServiceProvider extends ServiceProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
return $request->user()
|
return $request->user()
|
||||||
? Limit::perMinute(config('app.api_rate_limit_authenticated_per_minute'))->by($request->user()->id)
|
? Limit::perMinute(200)->by($request->user()->id)
|
||||||
: Limit::perMinute(config('app.api_rate_limit_guest_per_minute'))->by($request->ip());
|
: Limit::perMinute(60)->by($request->ip());
|
||||||
});
|
});
|
||||||
|
|
||||||
$this->routes(function (): void {
|
$this->routes(function (): void {
|
||||||
|
|||||||
@@ -158,21 +158,6 @@ return [
|
|||||||
|
|
||||||
'pagination_per_page_default' => (int) env('PAGINATION_PER_PAGE_DEFAULT', 15),
|
'pagination_per_page_default' => (int) env('PAGINATION_PER_PAGE_DEFAULT', 15),
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| API Rate Limiting
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| The number of API requests allowed per minute, counted per user for
|
|
||||||
| authenticated requests and per IP address for guest requests. These
|
|
||||||
| limits are only enforced when the application runs in production.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'api_rate_limit_authenticated_per_minute' => (int) (env('API_RATE_LIMIT_AUTH_PER_MINUTE') ?: 200),
|
|
||||||
|
|
||||||
'api_rate_limit_guest_per_minute' => (int) (env('API_RATE_LIMIT_GUEST_PER_MINUTE') ?: 60),
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Encryption Key
|
| Encryption Key
|
||||||
|
|||||||
@@ -1,230 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
use function Laravel\Prompts\info as consoleInfo;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* PostgreSQL cannot build or drop an index concurrently inside a transaction.
|
|
||||||
* Keeping this migration non-transactional prevents long write locks in production.
|
|
||||||
*
|
|
||||||
* @var bool
|
|
||||||
*/
|
|
||||||
public $withinTransaction = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
// Tenant-scoped client pagination ordered by newest first; also covers the organization FK.
|
|
||||||
$this->runIndexOperation('create', 'clients_organization_created_id_index', 'CREATE INDEX CONCURRENTLY clients_organization_created_id_index ON clients (organization_id, created_at DESC, id)');
|
|
||||||
|
|
||||||
// Tenant-scoped project pagination ordered by newest first; also covers the organization FK.
|
|
||||||
$this->runIndexOperation('create', 'projects_organization_created_id_index', 'CREATE INDEX CONCURRENTLY projects_organization_created_id_index ON projects (organization_id, created_at DESC, id)');
|
|
||||||
// Speeds client relationship loads and the FK check when a client is deleted or its ID changes.
|
|
||||||
$this->runIndexOperation('create', 'projects_client_id_index', 'CREATE INDEX CONCURRENTLY projects_client_id_index ON projects (client_id)');
|
|
||||||
|
|
||||||
// Tenant-scoped task pagination ordered by newest first; also covers the organization FK.
|
|
||||||
$this->runIndexOperation('create', 'tasks_organization_created_id_index', 'CREATE INDEX CONCURRENTLY tasks_organization_created_id_index ON tasks (organization_id, created_at DESC, id)');
|
|
||||||
// Speeds project task lists and the FK check when a project is deleted or its ID changes.
|
|
||||||
$this->runIndexOperation('create', 'tasks_project_id_index', 'CREATE INDEX CONCURRENTLY tasks_project_id_index ON tasks (project_id)');
|
|
||||||
|
|
||||||
// Tenant-scoped tag pagination ordered by newest first; also covers the organization FK.
|
|
||||||
$this->runIndexOperation('create', 'tags_organization_created_id_index', 'CREATE INDEX CONCURRENTLY tags_organization_created_id_index ON tags (organization_id, created_at DESC, id)');
|
|
||||||
|
|
||||||
// Tenant-scoped report pagination ordered by newest first; also covers the organization FK.
|
|
||||||
$this->runIndexOperation('create', 'reports_organization_created_id_index', 'CREATE INDEX CONCURRENTLY reports_organization_created_id_index ON reports (organization_id, created_at DESC, id)');
|
|
||||||
|
|
||||||
// Tenant-scoped member pagination; the existing (organization_id, user_id) unique index remains for membership lookup.
|
|
||||||
$this->runIndexOperation('create', 'members_organization_created_id_index', 'CREATE INDEX CONCURRENTLY members_organization_created_id_index ON members (organization_id, created_at DESC, id)');
|
|
||||||
// Supports reverse user-to-membership lookups and the FK check when a user is deleted or its ID changes.
|
|
||||||
$this->runIndexOperation('create', 'members_user_id_index', 'CREATE INDEX CONCURRENTLY members_user_id_index ON members (user_id)');
|
|
||||||
|
|
||||||
// The existing (project_id, user_id) unique index covers project_id, but member_id and legacy user_id need reverse indexes.
|
|
||||||
$this->runIndexOperation('create', 'project_members_member_id_index', 'CREATE INDEX CONCURRENTLY project_members_member_id_index ON project_members (member_id)');
|
|
||||||
$this->runIndexOperation('create', 'project_members_user_id_index', 'CREATE INDEX CONCURRENTLY project_members_user_id_index ON project_members (user_id)');
|
|
||||||
|
|
||||||
// Supports organization deletion/current-team cleanup and the FK check on users.current_team_id.
|
|
||||||
$this->runIndexOperation('create', 'users_current_team_id_index', 'CREATE INDEX CONCURRENTLY users_current_team_id_index ON users (current_team_id)');
|
|
||||||
|
|
||||||
// Filament loads the newest audits first; this avoids scanning and sorting the large append-only audit table.
|
|
||||||
$this->runIndexOperation('create', 'audits_created_at_index', 'CREATE INDEX CONCURRENTLY audits_created_at_index ON audits (created_at DESC)');
|
|
||||||
|
|
||||||
// Main tenant time-entry range/pagination path, including its start DESC, id ordering; also covers the organization FK.
|
|
||||||
$this->runIndexOperation('create', 'time_entries_organization_start_id_index', 'CREATE INDEX CONCURRENTLY time_entries_organization_start_id_index ON time_entries (organization_id, start DESC, id)');
|
|
||||||
// Filament lists time entries globally by creation time, so the tenant-prefixed index cannot provide this ordering.
|
|
||||||
$this->runIndexOperation('create', 'time_entries_created_at_index', 'CREATE INDEX CONCURRENTLY time_entries_created_at_index ON time_entries (created_at DESC)');
|
|
||||||
// Dashboard history is consistently filtered by user and organization, then bounded by start; user first also covers its FK.
|
|
||||||
$this->runIndexOperation('create', 'time_entries_user_organization_start_index', 'CREATE INDEX CONCURRENTLY time_entries_user_organization_start_index ON time_entries (user_id, organization_id, start)');
|
|
||||||
// Member timelines, overlap checks, and billable-rate updates start with member_id; also covers its FK.
|
|
||||||
$this->runIndexOperation('create', 'time_entries_member_start_index', 'CREATE INDEX CONCURRENTLY time_entries_member_start_index ON time_entries (member_id, start)');
|
|
||||||
// These relationship/filter indexes also prevent full scans for FK checks when parent rows change or are deleted.
|
|
||||||
$this->runIndexOperation('create', 'time_entries_project_id_index', 'CREATE INDEX CONCURRENTLY time_entries_project_id_index ON time_entries (project_id)');
|
|
||||||
$this->runIndexOperation('create', 'time_entries_task_id_index', 'CREATE INDEX CONCURRENTLY time_entries_task_id_index ON time_entries (task_id)');
|
|
||||||
$this->runIndexOperation('create', 'time_entries_client_id_index', 'CREATE INDEX CONCURRENTLY time_entries_client_id_index ON time_entries (client_id)');
|
|
||||||
// Active-timer checks touch only open entries, so a partial index stays small while serving the hot member_id lookup.
|
|
||||||
$this->runIndexOperation('create', 'time_entries_active_member_index', 'CREATE INDEX CONCURRENTLY time_entries_active_member_index ON time_entries (member_id) WHERE "end" IS NULL');
|
|
||||||
// whereJsonContains(tags, tag_id) compiles to JSONB containment, which is supported by a GIN index.
|
|
||||||
$this->runIndexOperation('create', 'time_entries_tags_gin_index', 'CREATE INDEX CONCURRENTLY time_entries_tags_gin_index ON time_entries USING GIN (tags)');
|
|
||||||
|
|
||||||
// Passport already indexes user_id; these indexes cover the other FK used during OAuth client deletion/update.
|
|
||||||
$this->runIndexOperation('create', 'oauth_access_tokens_client_id_index', 'CREATE INDEX CONCURRENTLY oauth_access_tokens_client_id_index ON oauth_access_tokens (client_id)');
|
|
||||||
$this->runIndexOperation('create', 'oauth_auth_codes_client_id_index', 'CREATE INDEX CONCURRENTLY oauth_auth_codes_client_id_index ON oauth_auth_codes (client_id)');
|
|
||||||
|
|
||||||
// owner_id is already the leading column of oauth_clients_owner_id_owner_type_index.
|
|
||||||
$this->runIndexOperation('drop', 'oauth_clients_user_id_index', 'DROP INDEX CONCURRENTLY oauth_clients_user_id_index');
|
|
||||||
// Public report lookup starts with the unique share_secret index; no query filters only by this boolean.
|
|
||||||
$this->runIndexOperation('drop', 'reports_is_public_index', 'DROP INDEX CONCURRENTLY reports_is_public_index');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
$indexes = [
|
|
||||||
'clients_organization_created_id_index',
|
|
||||||
'projects_organization_created_id_index',
|
|
||||||
'projects_client_id_index',
|
|
||||||
'tasks_organization_created_id_index',
|
|
||||||
'tasks_project_id_index',
|
|
||||||
'tags_organization_created_id_index',
|
|
||||||
'reports_organization_created_id_index',
|
|
||||||
'members_organization_created_id_index',
|
|
||||||
'members_user_id_index',
|
|
||||||
'project_members_member_id_index',
|
|
||||||
'project_members_user_id_index',
|
|
||||||
'users_current_team_id_index',
|
|
||||||
'audits_created_at_index',
|
|
||||||
'time_entries_organization_start_id_index',
|
|
||||||
'time_entries_created_at_index',
|
|
||||||
'time_entries_user_organization_start_index',
|
|
||||||
'time_entries_member_start_index',
|
|
||||||
'time_entries_project_id_index',
|
|
||||||
'time_entries_task_id_index',
|
|
||||||
'time_entries_client_id_index',
|
|
||||||
'time_entries_active_member_index',
|
|
||||||
'time_entries_tags_gin_index',
|
|
||||||
'oauth_access_tokens_client_id_index',
|
|
||||||
'oauth_auth_codes_client_id_index',
|
|
||||||
];
|
|
||||||
|
|
||||||
$concurrently = DB::transactionLevel() === 0 ? ' CONCURRENTLY' : '';
|
|
||||||
foreach ($indexes as $index) {
|
|
||||||
$this->runIndexOperation('drop', $index, 'DROP INDEX'.$concurrently.' IF EXISTS '.$index);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->runIndexOperation('create', 'oauth_clients_user_id_index', 'CREATE INDEX'.$concurrently.' oauth_clients_user_id_index ON oauth_clients (owner_id)');
|
|
||||||
$this->runIndexOperation('create', 'reports_is_public_index', 'CREATE INDEX'.$concurrently.' reports_is_public_index ON reports (is_public)');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function runIndexOperation(string $operation, string $index, string $statement): void
|
|
||||||
{
|
|
||||||
$indexState = $this->indexState($index);
|
|
||||||
|
|
||||||
if ($operation === 'create' && $indexState === ['valid' => true, 'ready' => true]) {
|
|
||||||
$this->writeProgress(sprintf('Skipping index [%s] because it already exists and is valid', $index));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($operation === 'drop' && $indexState === null) {
|
|
||||||
$this->writeProgress(sprintf('Skipping index [%s] because it does not exist', $index));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($operation === 'create' && $indexState !== null) {
|
|
||||||
$this->writeProgress(sprintf(
|
|
||||||
'Index [%s] exists but is incomplete (valid=%s, ready=%s); dropping it before rebuilding',
|
|
||||||
$index,
|
|
||||||
$indexState['valid'] ? 'true' : 'false',
|
|
||||||
$indexState['ready'] ? 'true' : 'false',
|
|
||||||
));
|
|
||||||
$this->executeIndexStatement(
|
|
||||||
'drop incomplete',
|
|
||||||
$index,
|
|
||||||
(DB::transactionLevel() === 0 ? 'DROP INDEX CONCURRENTLY ' : 'DROP INDEX ').$this->quoteIdentifier($index),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->executeIndexStatement($operation, $index, $statement);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function executeIndexStatement(string $operation, string $index, string $statement): void
|
|
||||||
{
|
|
||||||
$startedAt = microtime(true);
|
|
||||||
$this->writeProgress(sprintf('Starting to %s index [%s]', $operation, $index));
|
|
||||||
|
|
||||||
try {
|
|
||||||
DB::statement($statement);
|
|
||||||
} catch (Throwable $exception) {
|
|
||||||
$this->writeProgress(sprintf(
|
|
||||||
'Failed to %s index [%s] after %.2f seconds: %s',
|
|
||||||
$operation,
|
|
||||||
$index,
|
|
||||||
microtime(true) - $startedAt,
|
|
||||||
$exception->getMessage(),
|
|
||||||
));
|
|
||||||
|
|
||||||
throw $exception;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->writeProgress(sprintf(
|
|
||||||
'Finished %s index [%s] in %.2f seconds',
|
|
||||||
$operation === 'create' ? 'creating' : 'dropping',
|
|
||||||
$index,
|
|
||||||
microtime(true) - $startedAt,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{valid: bool, ready: bool}|null
|
|
||||||
*/
|
|
||||||
private function indexState(string $index): ?array
|
|
||||||
{
|
|
||||||
$state = DB::selectOne(
|
|
||||||
<<<'SQL'
|
|
||||||
SELECT pg_index.indisvalid::int AS valid, pg_index.indisready::int AS ready
|
|
||||||
FROM pg_index
|
|
||||||
JOIN pg_class ON pg_class.oid = pg_index.indexrelid
|
|
||||||
JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace
|
|
||||||
WHERE pg_namespace.nspname = current_schema()
|
|
||||||
AND pg_class.relname = ?
|
|
||||||
SQL,
|
|
||||||
[$index],
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($state === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'valid' => (bool) $state->valid,
|
|
||||||
'ready' => (bool) $state->ready,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function quoteIdentifier(string $identifier): string
|
|
||||||
{
|
|
||||||
return DB::connection()->getQueryGrammar()->wrap($identifier);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function writeProgress(string $message): void
|
|
||||||
{
|
|
||||||
if (app()->runningUnitTests()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$message = sprintf('[%s] %s', date(DATE_ATOM), $message);
|
|
||||||
Log::info($message);
|
|
||||||
consoleInfo($message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"Billing": {
|
"Billing": {
|
||||||
"repository": "solidtime-io/extension-billing",
|
"repository": "solidtime-io/extension-billing",
|
||||||
"ref": "v0.0.5"
|
"ref": "v0.0.3"
|
||||||
},
|
},
|
||||||
"Services": {
|
"Services": {
|
||||||
"repository": "solidtime-io/extension-services",
|
"repository": "solidtime-io/extension-services",
|
||||||
"ref": "v0.0.2"
|
"ref": "v0.0.1"
|
||||||
},
|
},
|
||||||
"Invoicing": {
|
"Invoicing": {
|
||||||
"repository": "solidtime-io/extension-invoicing",
|
"repository": "solidtime-io/extension-invoicing",
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ const showResultModal = ref(false);
|
|||||||
for="file-upload"
|
for="file-upload"
|
||||||
class="relative cursor-pointer rounded-md font-semibold text-text-primary focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 focus-within:ring-offset-gray-900 hover:text-indigo-500">
|
class="relative cursor-pointer rounded-md font-semibold text-text-primary focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 focus-within:ring-offset-gray-900 hover:text-indigo-500">
|
||||||
<span v-if="files">{{ filenames }}</span>
|
<span v-if="files">{{ filenames }}</span>
|
||||||
<span v-else>Upload the Export file</span>
|
<span v-else>Upload a Toggl/Clockify Export</span>
|
||||||
<input
|
<input
|
||||||
id="file-upload"
|
id="file-upload"
|
||||||
ref="importFile"
|
ref="importFile"
|
||||||
|
|||||||
@@ -25,21 +25,25 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
|||||||
<template>
|
<template>
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay
|
<DialogOverlay
|
||||||
class="fixed top-0 left-0 z-50 w-screen h-screen [height:100dvh] backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0">
|
class="fixed inset-0 z-50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0">
|
||||||
<div class="absolute inset-0 bg-default-background opacity-30" />
|
<div class="absolute inset-0 bg-default-background opacity-30" />
|
||||||
<div
|
|
||||||
class="absolute inset-0 overflow-y-auto overscroll-contain flex items-start justify-center px-2">
|
|
||||||
<DialogContent
|
|
||||||
v-bind="forwarded"
|
|
||||||
:class="
|
|
||||||
cn(
|
|
||||||
'my-3 md:my-14 xl:my-24 bg-default-background grid w-full max-w-lg border border-border-tertiary shadow-lg duration-200 rounded-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
|
||||||
props.class
|
|
||||||
)
|
|
||||||
">
|
|
||||||
<slot />
|
|
||||||
</DialogContent>
|
|
||||||
</div>
|
|
||||||
</DialogOverlay>
|
</DialogOverlay>
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'fixed top-0 left-0 z-50 pointer-events-none w-screen h-screen flex items-start px-2 pt-3 md:pt-14 xl:pt-24 justify-center overflow-auto'
|
||||||
|
)
|
||||||
|
">
|
||||||
|
<DialogContent
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'pointer-events-auto bg-default-background grid w-full max-w-lg border border-border-tertiary shadow-lg duration-200 rounded-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
props.class
|
||||||
|
)
|
||||||
|
">
|
||||||
|
<slot />
|
||||||
|
</DialogContent>
|
||||||
|
</div>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user