diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 380331ad6f..1cbade188f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,17 +1,50 @@ -### Description +# Description -*Please include a short description of the suggested change and the reasoning behind the approach you have chosen.* + +Preview link: -### Link to ticket +Resolves [](https://leantime.pearshadow.com/tickets) -*Please add a link to the GitHub issue being addressed by this change.* -### Type +# Screenshots -- [ ] Fix -- [ ] Feature -- [ ] Cleanup +Please make sure to include both desktop and mobile screenshots. -### Screenshot of the result +### Before -*If your change affects the user interface, you should include a screenshot of the result with the pull request.* + + + + + + + + + +
DesktopMobile
SCREENSHOTSCREENSHOT
+ +### After + + + + + + + + + + +
DesktopMobile
SCREENSHOTSCREENSHOT
+ +### Figma + + + + + + + + + + +
DesktopMobile
SCREENSHOTSCREENSHOT
diff --git a/.github/SECRETS-SETUP.md b/.github/SECRETS-SETUP.md new file mode 100644 index 0000000000..d1d0e5a726 --- /dev/null +++ b/.github/SECRETS-SETUP.md @@ -0,0 +1,73 @@ +# GitHub Secrets - Quick Setup + +## Required Secrets + +Go to: **Settings → Secrets and variables → Actions → New repository secret** + +> **Note:** Replace all placeholder values below (e.g., `your.server.ip.address`, `your-ssh-username`) with your actual server configuration values. + +--- + +### 1. SSH_PRIVATE_KEY + +```bash +# Display key content: +cat ~/.ssh/your-private-key +``` + +Copy the **entire output** (including `-----BEGIN ... KEY-----` and `-----END ... KEY-----`) and paste into GitHub Secret. + +--- + +### 2. SSH_HOST + +**Value:** `your.server.ip.address` + +--- + +### 3. SSH_USER + +**Value:** `your-ssh-username` + +--- + +### 4. STAGING_PATH + +**Value:** `your-leantime-staging-path` + +--- + +### 5. PRODUCTION_PATH + +**Value:** `your-leantime-production-path` + +--- + +### 6. PRODUCTION_URL (optional) + +**Value:** `https://your-production-domain.com` + +Replace with your actual production application URL for health checks. + +--- + +## Verification + +After adding all secrets, you should see: + +``` +✓ SSH_PRIVATE_KEY +✓ SSH_HOST +✓ SSH_USER +✓ STAGING_PATH +✓ PRODUCTION_PATH +✓ PRODUCTION_URL +``` + +## Testing + +1. Ensure develop branch exists: `git checkout develop` +2. Push: `git push -u origin develop` +3. GitHub Actions will automatically start deployment +4. Monitor in **Actions** tab + diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000000..3aff143eac --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,225 @@ +name: Deploy Leantime + +on: + push: + branches: + - develop + - master + workflow_dispatch: + +jobs: + deploy: + name: Deploy to Server + runs-on: ubuntu-latest + + steps: + - name: Setup SSH + env: + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + SSH_HOST: ${{ secrets.SSH_HOST }} + run: | + mkdir -p ~/.ssh + echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + ssh-keyscan -H $SSH_HOST >> ~/.ssh/known_hosts + chmod 644 ~/.ssh/known_hosts + + - name: Deploy to Staging + if: github.ref == 'refs/heads/develop' + env: + SSH_HOST: ${{ secrets.SSH_HOST }} + SSH_USER: ${{ secrets.SSH_USER }} + STAGING_PATH: ${{ secrets.STAGING_PATH }} + STAGING_ENV_FILE: ${{ secrets.STAGING_ENV_FILE }} + run: | + echo "Deploying to STAGING" + + # Deploy using git pull on server + ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no $SSH_USER@$SSH_HOST << ENDSSH + set -e # Exit on error + + echo "Changing to deployment directory..." + cd $STAGING_PATH + + echo "Fetching latest changes..." + git fetch origin develop + + echo "Creating backup tag..." + BACKUP_TAG="backup-staging-$(date +%Y%m%d_%H%M%S)" + git tag -f $BACKUP_TAG HEAD + echo "Backup tag created: $BACKUP_TAG" + + echo "Pulling latest code from develop branch..." + git pull origin develop + + echo "Installing Composer dependencies..." + composer install --no-dev --optimize-autoloader --no-interaction + + echo "Installing NPM dependencies..." + npm ci + + echo "Building frontend assets..." + npx mix --production + + echo "Generating blocklist..." + node generateBlocklist.mjs + + echo "Clearing cache..." + # Clear cache files + sudo find bootstrap/cache -type f ! -name '.gitignore' -delete 2>/dev/null || true + sudo find storage/framework/cache -type f ! -name '.gitignore' -delete 2>/dev/null || true + sudo find storage/framework/sessions -type f ! -name '.gitignore' -delete 2>/dev/null || true + sudo find storage/framework/views -type f ! -name '.gitignore' -delete 2>/dev/null || true + + echo "Setting proper file permissions..." + # Set ownership for application files + sudo chown -R www-data:www-data $STAGING_PATH + + # Secure .env file permissions + sudo chmod 600 config/.env 2>/dev/null || true + + # Make writable directories + sudo chmod -R 775 storage bootstrap/cache userfiles public/userfiles 2>/dev/null || true + + echo "Reloading services..." + # Reload PHP-FPM to clear opcache + sudo systemctl reload php8.2-fpm + + # Reload nginx + sudo systemctl reload nginx + + echo "Staging deployment completed successfully!" + echo "Commit: $(git rev-parse --short HEAD)" + echo "Rollback available: git reset --hard $BACKUP_TAG" + ENDSSH + + - name: Health Check Staging + if: github.ref == 'refs/heads/develop' + env: + STAGING_URL: ${{ secrets.STAGING_URL }} + run: | + echo " Running staging health check..." + sleep 3 + + response=$(curl -s -o /dev/null -w "%{http_code}" $STAGING_URL/healthCheck.php 2>/dev/null || echo "000") + + if [ "$response" = "200" ]; then + echo " Staging health check passed! (HTTP $response)" + else + echo " Warning: Health check returned HTTP $response" + echo "Please verify the staging deployment manually at $STAGING_URL" + fi + + - name: Deploy to Production + if: github.ref == 'refs/heads/master' + env: + SSH_HOST: ${{ secrets.SSH_HOST }} + SSH_USER: ${{ secrets.SSH_USER }} + PRODUCTION_PATH: ${{ secrets.PRODUCTION_PATH }} + run: | + echo "Deploying to PRODUCTION using Git Pull..." + + # Deploy using git pull on server + ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no $SSH_USER@$SSH_HOST << 'ENDSSH' + set -e # Exit on error + + echo "Changing to deployment directory..." + cd $PRODUCTION_PATH + + echo "Fetching latest changes..." + git fetch origin master + + echo "Creating backup tag..." + BACKUP_TAG="backup-production-$(date +%Y%m%d_%H%M%S)" + git tag -f $BACKUP_TAG HEAD + echo "Backup tag created: $BACKUP_TAG" + + echo "Pulling latest code from master branch..." + git pull origin master + + echo "Installing Composer dependencies..." + composer install --no-dev --optimize-autoloader --no-interaction + + echo "Installing NPM dependencies..." + npm ci --production + + echo "Building frontend assets..." + npx mix --production + + echo "Generating blocklist..." + node generateBlocklist.mjs + + echo "Clearing cache..." + # Clear cache files + sudo find bootstrap/cache -type f ! -name '.gitignore' -delete 2>/dev/null || true + sudo find storage/framework/cache -type f ! -name '.gitignore' -delete 2>/dev/null || true + sudo find storage/framework/sessions -type f ! -name '.gitignore' -delete 2>/dev/null || true + sudo find storage/framework/views -type f ! -name '.gitignore' -delete 2>/dev/null || true + + echo "Setting proper file permissions..." + # Set ownership for application files + sudo chown -R www-data:www-data $PRODUCTION_PATH + + # Secure .env file permissions + sudo chmod 600 config/.env 2>/dev/null || true + + # Make writable directories + sudo chmod -R 775 storage bootstrap/cache userfiles public/userfiles 2>/dev/null || true + + echo "Reloading services..." + # Reload PHP-FPM to clear opcache + sudo systemctl reload php8.2-fpm + + # Reload nginx + sudo systemctl reload nginx + + echo "Production deployment completed successfully!" + echo "Commit: $(git rev-parse --short HEAD)" + echo "Rollback available: git reset --hard $BACKUP_TAG" + ENDSSH + + - name: Health Check Production + if: github.ref == 'refs/heads/master' + env: + PRODUCTION_URL: ${{ secrets.PRODUCTION_URL }} + run: | + echo " Running production health check..." + sleep 5 + + response=$(curl -s -o /dev/null -w "%{http_code}" $PRODUCTION_URL/healthCheck.php 2>/dev/null || echo "000") + + if [ "$response" = "200" ]; then + echo " Production health check passed! (HTTP $response)" + else + echo " ERROR: Production health check failed! (HTTP $response)" + echo " Please verify the deployment manually at $PRODUCTION_URL" + exit 1 + fi + + - name: Deployment Summary + if: always() + run: | + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + if [ "${{ github.ref }}" = "refs/heads/develop" ]; then + echo " STAGING Deployment Summary" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "Branch: develop" + echo "Environment: Staging" + echo "Server: ${{ secrets.SSH_HOST }}" + echo "Path: ${{ secrets.STAGING_PATH }}" + echo "URL: ${{ secrets.STAGING_URL }}" + else + echo " PRODUCTION Deployment Summary" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "Branch: master" + echo "Environment: Production" + echo "Server: ${{ secrets.SSH_HOST }}" + echo "Path: ${{ secrets.PRODUCTION_PATH }}" + echo "URL: ${{ secrets.PRODUCTION_URL }}" + fi + echo "Commit: ${{ github.sha }}" + echo "Actor: ${{ github.actor }}" + echo "Status: ${{ job.status }}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + diff --git a/.gitignore b/.gitignore index 7e5fec780f..8c39c258ee 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,6 @@ config/.env /.serena /.playwright-mcp /.idea + +.env +config/.env diff --git a/.idea/php.xml b/.idea/php.xml index 38c2e88da0..1f9ad773bc 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -1,51 +1,5 @@ - - $PROJECT_DIR$/storage/framework/views - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -109,25 +63,6 @@ - - - - - - - - - - - - - - - - - - - @@ -310,7 +245,6 @@ - @@ -354,35 +288,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/DEPLOYMENT-SETUP.md b/DEPLOYMENT-SETUP.md new file mode 100644 index 0000000000..aa1dca10ef --- /dev/null +++ b/DEPLOYMENT-SETUP.md @@ -0,0 +1,183 @@ +# Leantime CI/CD Setup Guide + +## Overview + +This CI/CD pipeline automatically deploys the Leantime application to your Ubuntu server: + +- **Develop** branch → `STAGING_PATH` (Staging environment) +- **Master** branch → `PRODUCTION_PATH` (Production environment) + +> **Note:** Server connection details (SSH host, user, and key) are configured via GitHub Secrets (see Step 1 below). + +--- + +## Setup + +### Step 1: Add GitHub Secrets + +Go to: **GitHub Repository → Settings → Secrets and variables → Actions** + +Click **"New repository secret"** and add the following: + +#### 1. SSH_PRIVATE_KEY + +Content of your SSH private key file: + +```bash +# On your local machine, display the key content: +cat ~/.ssh/your-private-key +``` + +Copy the **entire output** (including `-----BEGIN ... KEY-----` and `-----END ... KEY-----`) + +**Name:** `SSH_PRIVATE_KEY` +**Value:** [paste entire key content] + +#### 2. SSH_HOST + +**Name:** `SSH_HOST` +**Value:** `your.server.ip.address` + +#### 3. SSH_USER + +**Name:** `SSH_USER` +**Value:** `your-ssh-username` + +#### 4. STAGING_PATH + +**Name:** `STAGING_PATH` +**Value:** `/path/to/your/staging/deployment` + +#### 5. PRODUCTION_PATH + +**Name:** `PRODUCTION_PATH` +**Value:** `/path/to/your/production/deployment` + +#### 6. PRODUCTION_URL (optional, for health check) + +**Name:** `PRODUCTION_URL` +**Value:** `https://yourdomain.com` (replace with actual URL) + +--- + +### Step 2: Verify SSH Access + +On your server, ensure the SSH key is added: + +```bash +# Connect to server +ssh -i ~/.ssh/your-private-key your-ssh-username@your.server.ip.address + +# Check if authorized_keys exists +cat ~/.ssh/authorized_keys + +# If not added, add it: +cat ~/.ssh/your-private-key.pub >> ~/.ssh/authorized_keys +chmod 600 ~/.ssh/authorized_keys +``` + +--- + +### Step 3: Prepare Directories on Server + +```bash +# Connect to server +ssh -i ~/.ssh/your-private-key your-ssh-username@your.server.ip.address + +# Staging directory +sudo mkdir -p /path/to/staging/deployment +sudo chown -R www-data:www-data /path/to/staging/deployment +sudo chmod -R 775 /path/to/staging/deployment + +# Production directory +sudo mkdir -p /path/to/production/deployment +sudo chown -R www-data:www-data /path/to/production/deployment +sudo chmod -R 775 /path/to/production/deployment +``` + +--- + +### Step 4: Check .env Files + +```bash +# Staging .env +sudo nano /path/to/staging/deployment/config/.env + +# Production .env +sudo nano /path/to/production/deployment/config/.env +``` + +Ensure each .env has: +```ini +LEAN_DB_HOST=localhost +LEAN_DB_USER=your_db_user +LEAN_DB_PASSWORD=your_db_password +LEAN_DB_DATABASE=your_db_name +LEAN_SESSION_PASSWORD=random_32_characters +``` + +--- + +### Step 5: Configure sudoers (for nginx reload) + +```bash +# On the server +sudo visudo + +# Add this line at the end (replace 'your-ssh-username' with your actual SSH user): +your-ssh-username ALL=(ALL) NOPASSWD: /bin/systemctl reload nginx, /bin/chown, /bin/chmod +``` + +Save and exit (Ctrl+X, Y, Enter) + +--- + +## Monitoring Deployments + +### Viewing Deployment Process + +1. Go to GitHub repository +2. Click on **"Actions"** tab +3. You'll see all deployments + +### Manual Trigger + +In GitHub Actions: +1. Click on **"Deploy Leantime"** workflow +2. Click **"Run workflow"** +3. Select branch (develop or master) + +--- + +## 🔧 What the Pipeline Does + +### For Develop branch (Staging): + +1. Checks out code +2. Installs PHP dependencies (Composer) +3. Installs NPM dependencies +4. Builds frontend (CSS/JS) +5. Syncs files to server +6. Sets permissions +7. Clears cache +8. Restarts web server + +### For Master branch (Production): + +1. Everything from develop +2. **Runs health check** + +--- + +## What Does NOT Get Overwritten During Deployment + +The pipeline **does not modify** the following files on the server: + +- `config/.env` - Your configuration +- `userfiles/` - Uploaded files +- `public/userfiles/` - Public files +- `storage/logs/` - Logs +- `app/Plugins/` - Installed plugins + +--- + diff --git a/app/Command/MigrateCommand.php b/app/Command/MigrateCommand.php index 66c65a4ee9..c7e83550db 100644 --- a/app/Command/MigrateCommand.php +++ b/app/Command/MigrateCommand.php @@ -103,7 +103,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $usersRepo->patchUser($userId, ['password' => $adminPassword, 'status' => 'a']); $helperService = app()->make(Helper::class); - $helperService->createDefaultProject($userId, 'owner'); } if ($silent) { diff --git a/app/Core/Configuration/DefaultConfig.php b/app/Core/Configuration/DefaultConfig.php index 1d58aae379..089b7b0151 100644 --- a/app/Core/Configuration/DefaultConfig.php +++ b/app/Core/Configuration/DefaultConfig.php @@ -195,9 +195,9 @@ class DefaultConfig public string $sessionPassword = '3evBlq9zdUEuzKvVJHWWx3QzsQhturBApxwcws2m'; /** - * @var int How many minutes after inactivity should we logout? 480min = 8hours + * @var int How many seconds after inactivity should we logout? 28800seconds = 8hours */ - public int $sessionExpiration = 480; + public int $sessionExpiration = 28800; /** * @var bool. Sets whether the cookie should only be served via https diff --git a/app/Core/Configuration/laravelConfig.php b/app/Core/Configuration/laravelConfig.php index 0285af2128..2664e64947 100644 --- a/app/Core/Configuration/laravelConfig.php +++ b/app/Core/Configuration/laravelConfig.php @@ -341,7 +341,7 @@ | */ - 'lifetime' => env('LEAN_SESSION_EXPIRATION', 480), // 8 hours + 'lifetime' => env('LEAN_SESSION_EXPIRATION', 28800) / 60, // Convert seconds to minutes, default 8 hours 'expire_on_close' => false, diff --git a/app/Core/Middleware/AuthCheck.php b/app/Core/Middleware/AuthCheck.php index 2ac594fb13..df08ad89e2 100644 --- a/app/Core/Middleware/AuthCheck.php +++ b/app/Core/Middleware/AuthCheck.php @@ -77,9 +77,11 @@ public function handle(IncomingRequest $request, Closure $next): Response $response = $next($request); - if ($authCheckResponse === true) { - $this->setCookie($response); - } + // #4771 - Commented out custom cookie logic in favor of Laravel's standard session management + // This custom cookie was causing inconsistent session behavior + // if ($authCheckResponse === true) { + // $this->setCookie($response); + // } return $response; } @@ -164,25 +166,28 @@ public function redirectWithOrigin(string $route, string $origin, IncomingReques return new RedirectResponse($destination.$queryParams); } - public function setCookie($response): void - { - - // Set cookie to increase session timeout - $response->headers->setCookie(new \Symfony\Component\HttpFoundation\Cookie( - 'esl', // Extend Session Lifetime - 'true', - Date::instance( - Carbon::now()->addRealMinutes($this->config->get('session.lifetime')) - ), - $this->config->get('session.path'), - $this->config->get('session.domain'), - false, - $this->config->get('session.http_only', true), - false, - $this->config->get('session.same_site', null), - $this->config->get('session.partitioned', false) - )); - } + // #4771 - Commented out custom cookie extension logic + // This was causing type errors and inconsistent session behavior + // Laravel's standard session management handles this properly + // public function setCookie($response): void + // { + // + // // Set cookie to increase session timeout + // $response->headers->setCookie(new \Symfony\Component\HttpFoundation\Cookie( + // 'esl', // Extend Session Lifetime + // 'true', + // Date::instance( + // Carbon::now()->addRealMinutes($this->config->get('session.lifetime')) + // ), + // $this->config->get('session.path'), + // $this->config->get('session.domain'), + // false, + // $this->config->get('session.http_only', true), + // false, + // $this->config->get('session.same_site', null), + // $this->config->get('session.partitioned', false) + // )); + // } public function isPublicController($currentPath): bool { diff --git a/app/Core/Middleware/StartSession.php b/app/Core/Middleware/StartSession.php index edaa233263..3f9f029193 100644 --- a/app/Core/Middleware/StartSession.php +++ b/app/Core/Middleware/StartSession.php @@ -212,11 +212,14 @@ protected function startSession(IncomingRequest $request, $session) */ public function getSession(IncomingRequest $request) { + // #4771 - Commented out custom session reduction logic + // This was causing inconsistent session lifetimes for non-logged-in users + // All users now have consistent session duration managed by Laravel // Non logged in cookies will be reduced to 60min. // Extend Session Lifetime - if (! $request->cookies->has('esl')) { - app('config')->set('session.lifetime', 60); - } + // if (! $request->cookies->has('esl')) { + // app('config')->set('session.lifetime', 60); + // } return tap($this->manager->driver(), function ($session) use ($request) { $session->setId($request->cookies->get($session->getName())); diff --git a/app/Domain/Help/Controllers/FirstLogin.php b/app/Domain/Help/Controllers/FirstLogin.php index b633bdeeb5..1c51add046 100644 --- a/app/Domain/Help/Controllers/FirstLogin.php +++ b/app/Domain/Help/Controllers/FirstLogin.php @@ -21,35 +21,42 @@ public function init(Helper $helperService) * get - handle get requests */ public function get($params) - { - - $allSteps = $this->helperService->getFirstLoginSteps(); +{ + $allSteps = $this->helperService->getFirstLoginSteps(); + + if (empty($allSteps)) { + $settingsRepo = app()->make(\Leantime\Domain\Setting\Repositories\Setting::class); + $settingsRepo->saveSetting('user.'.session()?->get('userdata.id', -1).'.firstLoginCompleted', true); + + $content = ''; + return new \Illuminate\Http\Response($content); + } - $currentStepKey = collect($allSteps)->keys()->first(); + $currentStepKey = collect($allSteps)->keys()->first(); - if (isset($_GET['step']) && $_GET['step'] == 'end') { - $content = ' '; + if (isset($_GET['step']) && $_GET['step'] == 'end') { + $content = ' '; - return new \Illuminate\Http\Response($content); - } + return new \Illuminate\Http\Response($content); + } - if (isset($_GET['step']) && isset($allSteps[$_GET['step']])) { - $currentStepKey = (int) $_GET['step']; - } + if (isset($_GET['step']) && isset($allSteps[$_GET['step']])) { + $currentStepKey = (int) $_GET['step']; + } - $currentStep = $allSteps[$currentStepKey]; + $currentStep = $allSteps[$currentStepKey]; - /** @var OnboardingSteps $stepObject */ - $nextStepObject = app()->make($currentStep['class']); + /** @var OnboardingSteps $stepObject */ + $nextStepObject = app()->make($currentStep['class']); - $this->tpl->assign('currentStep', $currentStepKey); - $this->tpl->assign('nextStep', $currentStep['next']); + $this->tpl->assign('currentStep', $currentStepKey); + $this->tpl->assign('nextStep', $currentStep['next']); - return $this->tpl->displayPartial($nextStepObject->getTemplate()); - } + return $this->tpl->displayPartial($nextStepObject->getTemplate()); +} /** * post - handle post requests diff --git a/app/Domain/Help/Services/Helper.php b/app/Domain/Help/Services/Helper.php index b257b28aca..5bcecf2da0 100644 --- a/app/Domain/Help/Services/Helper.php +++ b/app/Domain/Help/Services/Helper.php @@ -155,8 +155,8 @@ public function getHelperModalByRoute(string $route): array public function getFirstLoginSteps(): array { $steps = [ - 0 => ['class' => "Leantime\Domain\Help\Services\FirstTaskStep", 'next' => 'end'], - ]; + // 0 => ['class' => "Leantime\Domain\Help\Services\FirstTaskStep", 'next' => 'end'], + ]; // make array of onboarding steps. $steps = self::dispatch_filter('filterSteps', $steps); @@ -173,16 +173,6 @@ public function getFirstLoginSteps(): array public function isFirstLogin(int $userId): bool { - // We don't have a project right now. Let's set it up - if (session('currentProject') === null || - session('currentProject') === 0 || - session('currentProject') === '' || - session('currentProject') === false) { - - $this->createDefaultProject($userId); - - } - $onboardingComplete = $this->settingsRepo->getSetting('user.'.$userId.'.firstLoginCompleted'); return ! isset($onboardingComplete) || $onboardingComplete === false; @@ -252,125 +242,4 @@ public function getOnboardingChecklist(int $userId): array|false return $checklist; } - - public function createDefaultProject(int $userId, string $role = 'editor') - { - - // Create Project - $projectService = app()->make(\Leantime\Domain\Projects\Services\Projects::class); - - $values = [ - 'name' => 'My Project', - 'details' => 'Welcome to your first project in Leantime!
This is your space to organize tasks, track goals, and plan your work. Feel free to modify anything here or create additional projects as you grow. This project is just for you to get started', - 'clientId' => 0, - 'hourBudget' => $values['hourBudget'] ?? 0, - 'assignedUsers' => [['id' => $userId, 'projectRole' => '']], - 'dollarBudget' => 0, - 'psettings' => 'restricted', - 'type' => 'project', - 'start' => null, - 'end' => null, - ]; - - $projectId = $projectService->addProject($values); - - // Create Milestone - $ticketService = app()->make(\Leantime\Domain\Tickets\Services\Tickets::class); - $values = [ - 'headline' => '🚀 Getting Started', - 'projectId' => $projectId, - 'editorId' => $userId, - 'userId' => $userId, - 'date' => dtHelper()->userNow()->formatDateTimeForDb(), - 'editFrom' => dtHelper()->userNow()->formatDateTimeForDb(), - 'editTo' => dtHelper()->userNow()->addDays(14)->formatDateTimeForDb(), - 'tags' => '#124F7D', - ]; - $milestoneId = $ticketService->quickAddMilestone($values); - - // Create Tasks - $values = [ - 'headline' => '', - 'description' => '', - 'projectId' => $projectId, - 'editorId' => $userId, - 'userId' => $userId, - 'dateToFinish' => dtHelper()->userNow()->addDays(3)->formatDateTimeForDb(), - 'milestone' => $milestoneId, - ]; - - $values['headline'] = '💬 Join our community chat'; - $values['description'] = 'Our community chat is a great resource to ask questions and get feedback on project set up. Community Chat'; - $values['dateToFinish'] = dtHelper()->userNow()->addDays(1)->formatDateForUser(); - $ticketService->quickAddTicket($values); - - if (in_array($role, ['admin', 'owner', 'manager'])) { - - $values['headline'] = '👥 Invite your team mates'; - $values['description'] = 'Whether you are working with someone or just need an accountability buddy. Using Leantime as a group helps to stay on track and motivated User Management'; - $values['dateToFinish'] = dtHelper()->userNow()->addDays(1)->formatDateForUser(); - $ticketService->quickAddTicket($values); - } - - $values['headline'] = '🎯 Learn More about Leantime\'s Project Structure'; - $values['description'] = 'We have a lot of additional resources on our help documentation. To learn more about project structure in Leantime and best practices visit: https://help.leantime.io'; - $values['dateToFinish'] = dtHelper()->userNow()->addDays(1)->formatDateForUser(); - $ticketService->quickAddTicket($values); - - $values['headline'] = '🎯 Create a Goal'; - $values['description'] = 'Goals are used to track and measure long term objectives. They should be measurable using metrics you can update on a regular basis. Goals and Milestones can be connected to view the execution progress while viewing the metric progress Project Goals'; - $values['dateToFinish'] = dtHelper()->userNow()->addDays(1)->formatDateForUser(); - $ticketService->quickAddTicket($values); - - $values['headline'] = '🚩 Create a Milestone'; - $values['description'] = 'Milestones allow you to categorize phases of your projects into discrete outcomes. Each milestone has a start and end date and should deliver some output Project Milestone'; - $values['dateToFinish'] = dtHelper()->userNow()->addDays(1)->formatDateForUser(); - $ticketService->quickAddTicket($values); - - $values['headline'] = '🗺️ Explore your Personal Project'; - $values['description'] = 'Your personal project is a space where you can organize your tasks, goals and work. You can access it via the project selector on the top or by clicking this link here: My Project'; - $values['dateToFinish'] = dtHelper()->userNow()->addDays(1)->formatDateForUser(); - $ticketService->quickAddTicket($values); - - $values['headline'] = '🖼️ Complete my Leantime profile'; - $values['description'] = 'Update profile picture and complete work preferences to personalize my experience. My Profile'; - $values['dateToFinish'] = dtHelper()->userNow()->addDays(1)->formatDateForUser(); - $ticketService->quickAddTicket($values); - - $values['headline'] = '📌 Create your first task'; - $values['description'] = ''; - $values['dateToFinish'] = dtHelper()->userNow(); - $values['status'] = 0; - $ticketService->quickAddTicket($values); - - // Create Goal - $goalService = app()->make(\Leantime\Domain\Goalcanvas\Services\Goalcanvas::class); - $values = [ - 'title' => 'My Goals', - 'author' => $userId, - 'projectId' => $projectId, - ]; - $currentCanvasId = $goalService->createGoalboard($values); - - $values = [ - 'description' => 'Tasks completed on time', // Metric - 'title' => 'Build My Productivity System', // Objective - 'box' => 'goal', - 'author' => $userId, - 'canvasId' => $currentCanvasId, - 'milestoneId' => $milestoneId, - 'startDate' => dtHelper()->userNow()->formatDateForUser(), - 'endDate' => dtHelper()->userNow()->addMonths(2)->formatDateForUser(), - 'metricType' => 'percent', - 'assignedTo' => $userId, - 'startValue' => '0', - 'currentValue' => '0', - 'endValue' => '80', - ]; - - $goalService->createGoal($values); - - $projectService->changeCurrentSessionProject($projectId); - - } } diff --git a/app/Domain/Help/register.php b/app/Domain/Help/register.php index 353843d189..bf998168b8 100644 --- a/app/Domain/Help/register.php +++ b/app/Domain/Help/register.php @@ -8,6 +8,5 @@ $userRole = session('userdata.role'); $helperService = app()->make(\Leantime\Domain\Help\Services\Helper::class); - $helperService->createDefaultProject($userId, $userRole); }); diff --git a/app/Domain/Tickets/Controllers/CloneTicket.php b/app/Domain/Tickets/Controllers/CloneTicket.php new file mode 100644 index 0000000000..8530890044 --- /dev/null +++ b/app/Domain/Tickets/Controllers/CloneTicket.php @@ -0,0 +1,58 @@ +ticketService = $ticketService; + } + + public function post($params): Response + { + $originalTicket = $this->ticketService->getTicket($params['id']); + + if (!$originalTicket) { + return Frontcontroller::redirect(BASE_URL.'/tickets/showKanban'); + } + $editFrom = new \DateTime($originalTicket->editFrom); + $editFrom->modify('+1 hour'); + $editTo = new \DateTime($originalTicket->editTo); + $editTo->modify('+1 hour'); + + $dateToFinish = new \DateTime($originalTicket->dateToFinish); + $dateToFinish->modify('+1 hour'); + + $cloneParams = [ + 'headline' => $originalTicket->headline, + 'description' => $originalTicket->description, + 'projectId' => $originalTicket->projectId, + 'status' => $originalTicket->status, + 'editorId' => $originalTicket->editorId, + 'type' => $originalTicket->type, + 'priority' => $originalTicket->priority, + 'storypoints' => $originalTicket->storypoints, + 'dateToFinish' => $dateToFinish->format('Y-m-d H:i:s'), + 'tags' => $originalTicket->tags, + 'milestoneid' => $originalTicket->milestoneid, + 'dependingTicketId' => $originalTicket->dependingTicketId, + 'editFrom' => $editFrom->format('Y-m-d H:i:s'), + 'editTo' => $editTo->format('Y-m-d H:i:s'), + 'planHours' => $originalTicket->planHours, + 'hourRemaining' => $originalTicket->hourRemaining, + ]; + + $newTicketId = $this->ticketService->addTicket($cloneParams); + error_log('Current language: ' . session('usersettings.language')); + $this->tpl->setNotification('To-Do was cloned succesfully!', 'success', 'ticket_cloned'); + return Frontcontroller::redirect(BASE_URL.'/tickets/showKanban'); + } +} \ No newline at end of file diff --git a/app/Domain/Tickets/Controllers/ShowTicket.php b/app/Domain/Tickets/Controllers/ShowTicket.php index 79933dc942..d252b5a825 100644 --- a/app/Domain/Tickets/Controllers/ShowTicket.php +++ b/app/Domain/Tickets/Controllers/ShowTicket.php @@ -125,6 +125,7 @@ public function get($params): Response $this->tpl->assign('kind', $this->timesheetService->getLoggableHourTypes()); $this->tpl->assign('ticketHours', $this->timesheetService->getLoggedHoursForTicketByDate($id)); + $this->tpl->assign('timesheetEntries', $this->timesheetService->getTimesheetEntriesForTicket($id)); $this->tpl->assign('userHours', $this->timesheetService->getUsersTicketHours($id, session('userdata.id'))); $this->tpl->assign('timesheetsAllHours', $this->timesheetService->getSumLoggedHoursForTicket($id)); diff --git a/app/Domain/Tickets/Js/ticketsController.js b/app/Domain/Tickets/Js/ticketsController.js index 06e842b7ad..01c861677e 100644 --- a/app/Domain/Tickets/Js/ticketsController.js +++ b/app/Domain/Tickets/Js/ticketsController.js @@ -15,7 +15,7 @@ leantime.ticketsController = (function () { ticketCounts[indexCol] = 0; } - var counting = jQuery(this).find('.moveable').length; + var counting = jQuery(this).find('.moveable:visible').length; ticketCounts[indexCol] += counting; }); @@ -23,11 +23,147 @@ leantime.ticketsController = (function () { }); jQuery(".widgettitle .count").each(function (index) { - jQuery(this).text(ticketCounts[index]); + var value = ticketCounts[index]; + if (value === undefined) { + value = 0; + } + jQuery(this).text(value); }); } + /* Legacy kanban search function - removed but kept as comment for reference + var applyKanbanSearchFilter = function (term) { + var normalized = ''; + if (typeof term === 'string') { + normalized = term.trim().toLowerCase(); + } + + var $wrapper = jQuery('#kanbanSearchWrapper'); + if ($wrapper.length) { + if (normalized.length > 0) { + $wrapper.addClass('has-value'); + } else { + $wrapper.removeClass('has-value'); + } + } + + var $cards = jQuery('.sortableTicketList.kanbanBoard .ticketBox.moveable'); + if (!$cards.length) { + return; + } + + if (!normalized.length) { + $cards.removeClass('kanban-search-hidden'); + } else { + $cards.each(function () { + var $card = jQuery(this); + var cached = $card.data('kanbanSearchText'); + + if (typeof cached !== 'string') { + cached = $card.text().toLowerCase(); + $card.data('kanbanSearchText', cached); + } + + if (cached.indexOf(normalized) !== -1) { + $card.removeClass('kanban-search-hidden'); + } else { + $card.addClass('kanban-search-hidden'); + } + }); + } + + countTickets(); + }; + */ + + var initKanbanSearch = function () { + /* Legacy implementation - removed but kept as comment for reference + jQuery(document).ready(function () { + var $input = jQuery('#kanbanBoardSearch'); + if (!$input.length) { + return; + } + + // Apply initial client-side filter if term exists in input + var initialTerm = $input.val(); + if (initialTerm && initialTerm.trim() !== '') { + applyKanbanSearchFilter(initialTerm); + } + + // Debounce function to avoid too many URL updates + var debounceTimer; + var debounceDelay = 800; // Wait 800ms after user stops typing + + $input.on('input', function () { + var searchTerm = jQuery(this).val(); + + // Apply client-side filter immediately for better UX + applyKanbanSearchFilter(searchTerm); + + // Clear existing timer + clearTimeout(debounceTimer); + + // Set new timer to update URL (server-side search) + debounceTimer = setTimeout(function() { + updateUrlWithSearchTerm(searchTerm); + }, debounceDelay); + }); + + // Handle Enter key to immediately trigger search + $input.on('keypress', function(e) { + if (e.which === 13) { // Enter key + e.preventDefault(); + clearTimeout(debounceTimer); + var searchTerm = jQuery(this).val(); + updateUrlWithSearchTerm(searchTerm); + } + }); + + jQuery('#kanbanBoardSearchClear').on('click', function () { + $input.val(''); + applyKanbanSearchFilter(''); + clearTimeout(debounceTimer); + updateUrlWithSearchTerm(''); + $input.trigger('focus'); + }); + + // Function to update URL with search term + function updateUrlWithSearchTerm(term) { + var url = new URL(window.location.href); + + if (term && term.trim() !== '') { + url.searchParams.set('term', term.trim()); + url.searchParams.set('search', 'true'); + } else { + url.searchParams.delete('term'); + // Only remove 'search' if no other search params exist + var hasOtherParams = url.searchParams.has('users') || + url.searchParams.has('milestone') || + url.searchParams.has('type') || + url.searchParams.has('priority') || + url.searchParams.has('status'); + if (!hasOtherParams) { + url.searchParams.delete('search'); + } + } + + // Update URL without page reload, then reload to apply server-side filter + window.location.href = url.toString(); + } + }); + */ + + jQuery(document).ready(function () { + if (window.leantime && leantime.kanbanSearch && typeof leantime.kanbanSearch.init === 'function') { + leantime.kanbanSearch.init({ + inputSelector: '#kanbanSearch', + wrapperSelector: '#kanbanSearchWrapper', + }); + } + }); + }; + var updateRemainingHours = function (element, id) { var value = jQuery(element).val(); @@ -315,7 +451,6 @@ leantime.ticketsController = (function () { date = jQuery.datepicker.parseDate(dateFormat, element.value); } catch ( error ) { date = null; - console.log(error); } return date; @@ -382,7 +517,6 @@ leantime.ticketsController = (function () { date = jQuery.datepicker.parseDate(dateFormat, element.value); } catch ( error ) { date = null; - console.log(error); } return date; @@ -478,7 +612,6 @@ leantime.ticketsController = (function () { date = jQuery.datepicker.parseDate(dateFormat, element.value); } catch ( error ) { date = null; - console.log(error); } return date; @@ -526,8 +659,6 @@ leantime.ticketsController = (function () { } ); - } else { - console.log("Ticket Controller: Effort data value not set correctly"); } }); @@ -575,8 +706,6 @@ leantime.ticketsController = (function () { } ); - } else { - console.log("Ticket Controller: Priority data value not set correctly"); } }); @@ -1624,6 +1753,7 @@ leantime.ticketsController = (function () { initAsyncInputChange:initAsyncInputChange, initDueDateTimePickers:initDueDateTimePickers, setUpKanbanColumns:setUpKanbanColumns, + initKanbanSearch:initKanbanSearch, addCommentTimesheetContent:addCommentTimesheetContent, initMilestoneTable:initMilestoneTable, initMilestoneDates:_initMilestoneDates, diff --git a/app/Domain/Tickets/Templates/partials/ticketsubmenu.blade.php b/app/Domain/Tickets/Templates/partials/ticketsubmenu.blade.php index fa295760db..cc15be36b1 100644 --- a/app/Domain/Tickets/Templates/partials/ticketsubmenu.blade.php +++ b/app/Domain/Tickets/Templates/partials/ticketsubmenu.blade.php @@ -11,7 +11,12 @@ -