Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47f2ab9cd4 | ||
|
|
36729f8f61 | ||
|
|
30b090852d | ||
|
|
f0c9568521 | ||
|
|
7c37eb58cb | ||
|
|
7a5b8dabff | ||
|
|
fef563be06 | ||
|
|
e14c707dfd | ||
|
|
fa7bb27ef5 | ||
|
|
21e9d2cab4 | ||
|
|
00ffcb1cf2 | ||
|
|
daba09472f | ||
|
|
f3208a481f | ||
|
|
a46542fcdd | ||
|
|
1ae2d9ac2c | ||
|
|
e41caa9cfe | ||
|
|
916f4ae101 | ||
| 45d389c67f | |||
|
|
7f12332cf6 | ||
| fe30f03b9f | |||
|
|
fc472d5dad | ||
| a0a2f27eac | |||
|
|
bd7adec2f0 | ||
| 9b6386c4ae | |||
|
|
9da1ae7ca1 | ||
| bc8bed3339 | |||
|
|
3fee678bd2 | ||
| be720178c2 | |||
|
|
eec0294f3e | ||
| 59a1c7956c | |||
|
|
e86949a1d7 | ||
|
|
7ca62bfc46 |
@@ -1,65 +0,0 @@
|
||||
name: Build Release Artefact
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: "8.4"
|
||||
extensions: mbstring, intl, pdo_pgsql, xml, curl, zip, gd
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Install backend deps (prod)
|
||||
env:
|
||||
APP_ENV: prod
|
||||
APP_DEBUG: "0"
|
||||
run: composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
|
||||
|
||||
- name: Build frontend (static)
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ npm run generate
|
||||
test -f .output/public/index.html
|
||||
|
||||
- name: Build artefact
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p release
|
||||
tar -czf "release/lesstime-${GITHUB_REF_NAME}.tar.gz" \
|
||||
.env \
|
||||
bin \
|
||||
config \
|
||||
migrations \
|
||||
public \
|
||||
src \
|
||||
vendor \
|
||||
composer.json \
|
||||
composer.lock \
|
||||
symfony.lock \
|
||||
frontend/.output
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: release/lesstime-${{ github.ref_name }}.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.3.18'
|
||||
app.version: '0.3.29'
|
||||
|
||||
@@ -109,23 +109,33 @@ export LESSTIME_IMAGE_TAG="$TAG"
|
||||
|
||||
echo "==> Deploying lesstime:${TAG}..."
|
||||
|
||||
echo "==> Enabling maintenance mode..."
|
||||
touch maintenance.on
|
||||
|
||||
echo "==> Pulling image..."
|
||||
docker compose pull
|
||||
sudo docker compose pull
|
||||
|
||||
echo "==> Starting container..."
|
||||
docker compose up -d
|
||||
sudo docker compose up -d
|
||||
|
||||
echo "==> Waiting for container to be ready..."
|
||||
sleep 3
|
||||
|
||||
echo "==> Extracting maintenance page..."
|
||||
mkdir -p public
|
||||
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
|
||||
|
||||
echo "==> Running migrations..."
|
||||
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
echo "==> Clearing cache..."
|
||||
docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||
docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||
|
||||
VERSION=$(docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
|
||||
echo "==> Disabling maintenance mode..."
|
||||
rm -f maintenance.on
|
||||
|
||||
VERSION=$(sudo docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
|
||||
echo "==> Deployed v${VERSION}"
|
||||
```
|
||||
|
||||
@@ -192,16 +202,33 @@ Creer `/etc/nginx/sites-available/lesstime.conf` :
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name project.malio-dev.fr;
|
||||
|
||||
client_max_body_size 55m;
|
||||
root /var/www/lesstime/public;
|
||||
|
||||
# Maintenance mode
|
||||
if (-f /var/www/lesstime/maintenance.on) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
error_page 503 @maintenance;
|
||||
|
||||
location @maintenance {
|
||||
rewrite ^(.*)$ /maintenance.html break;
|
||||
}
|
||||
|
||||
location = /maintenance.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 55m;
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -250,6 +277,8 @@ rm /tmp/lesstime.sql
|
||||
├── config/jwt/
|
||||
│ ├── private.pem
|
||||
│ └── public.pem
|
||||
├── public/
|
||||
│ └── maintenance.html # extrait automatiquement par deploy.sh
|
||||
└── uploads/
|
||||
```
|
||||
|
||||
|
||||
153
doc/setup-maintenance-mode.md
Normal file
153
doc/setup-maintenance-mode.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# Configuration du mode maintenance (nginx hote)
|
||||
|
||||
Guide pour activer le support du mode maintenance pilote par Central.
|
||||
Ces etapes sont a faire **une seule fois** par application sur le serveur de production.
|
||||
|
||||
Le principe : le nginx de l'hote (reverse proxy) verifie si un fichier `maintenance.on` existe dans le dossier de deploy. Si oui, il sert une page `maintenance.html` au lieu de proxifier vers le container Docker.
|
||||
|
||||
Central pilote la creation/suppression de ce fichier via ses volumes Docker.
|
||||
|
||||
## Ce qui a ete fait pour Lesstime
|
||||
|
||||
### 1. Deployer pour extraire la page maintenance
|
||||
|
||||
```bash
|
||||
cd /var/www/lesstime
|
||||
sudo ./deploy.sh
|
||||
```
|
||||
|
||||
Le `deploy.sh` extrait automatiquement `maintenance.html` du container vers `public/` :
|
||||
```
|
||||
mkdir -p public
|
||||
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
|
||||
```
|
||||
|
||||
### 2. Mettre a jour la conf nginx de l'hote
|
||||
|
||||
Remplacer le contenu de `/etc/nginx/sites-available/lesstime.conf` :
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name project.malio-dev.fr;
|
||||
|
||||
root /var/www/lesstime/public;
|
||||
|
||||
# Maintenance mode
|
||||
if (-f /var/www/lesstime/maintenance.on) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
error_page 503 @maintenance;
|
||||
|
||||
location @maintenance {
|
||||
rewrite ^(.*)$ /maintenance.html break;
|
||||
}
|
||||
|
||||
location = /maintenance.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 55m;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Recharger nginx
|
||||
|
||||
```bash
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 4. Verifier
|
||||
|
||||
- Depuis Central, activer la maintenance sur Lesstime
|
||||
- Ouvrir `http://project.malio-dev.fr` → doit afficher la page "Maintenance en cours"
|
||||
- Desactiver la maintenance depuis Central → le site revient
|
||||
|
||||
---
|
||||
|
||||
## A faire pour Inventory
|
||||
|
||||
Meme procedure :
|
||||
|
||||
### 1. Deployer pour extraire la page maintenance
|
||||
|
||||
```bash
|
||||
cd /var/www/inventory
|
||||
sudo ./deploy.sh
|
||||
```
|
||||
|
||||
> Si le `deploy.sh` ne contient pas encore l'extraction, mettre a jour le fichier depuis le repo (`infra/prod/deploy.sh`) ou executer manuellement :
|
||||
> ```bash
|
||||
> mkdir -p public
|
||||
> sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
|
||||
> ```
|
||||
|
||||
### 2. Mettre a jour la conf nginx de l'hote
|
||||
|
||||
Remplacer le contenu de `/etc/nginx/sites-available/inventory.conf` :
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name inventory.malio-dev.fr;
|
||||
|
||||
root /var/www/inventory/public;
|
||||
|
||||
# Maintenance mode
|
||||
if (-f /var/www/inventory/maintenance.on) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
error_page 503 @maintenance;
|
||||
|
||||
location @maintenance {
|
||||
rewrite ^(.*)$ /maintenance.html break;
|
||||
}
|
||||
|
||||
location = /maintenance.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8082;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Recharger nginx
|
||||
|
||||
```bash
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fonctionnement
|
||||
|
||||
```
|
||||
Central (container)
|
||||
└── touch /var/www/maintenance/lesstime/maintenance.on
|
||||
│ (volume Docker : /var/www/lesstime → /var/www/maintenance/lesstime)
|
||||
▼
|
||||
/var/www/lesstime/maintenance.on (hote)
|
||||
│
|
||||
▼
|
||||
nginx hote : if (-f /var/www/lesstime/maintenance.on) → 503
|
||||
│
|
||||
▼
|
||||
maintenance.html servie depuis /var/www/lesstime/public/
|
||||
```
|
||||
@@ -10,21 +10,17 @@
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
<MalioInputPassword
|
||||
v-model="form.tokenId"
|
||||
:label="$t('bookstack.settings.tokenId')"
|
||||
:placeholder="$t('bookstack.settings.tokenIdPlaceholder')"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<MalioInputText
|
||||
<MalioInputPassword
|
||||
v-model="form.tokenSecret"
|
||||
:label="$t('bookstack.settings.tokenSecret')"
|
||||
:placeholder="$t('bookstack.settings.tokenSecretPlaceholder')"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
/>
|
||||
<p v-if="hasToken && !form.tokenId && !form.tokenSecret" class="mt-1 text-xs text-green-600">
|
||||
{{ $t('bookstack.settings.tokenConfigured') }}
|
||||
|
||||
@@ -11,12 +11,10 @@
|
||||
/>
|
||||
|
||||
<div>
|
||||
<MalioInputText
|
||||
<MalioInputPassword
|
||||
v-model="form.token"
|
||||
:label="$t('gitea.settings.token')"
|
||||
:placeholder="$t('gitea.settings.tokenPlaceholder')"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
/>
|
||||
<p v-if="hasToken && !form.token" class="mt-1 text-xs text-green-600">
|
||||
{{ $t('gitea.settings.tokenConfigured') }}
|
||||
|
||||
@@ -22,11 +22,10 @@
|
||||
input-class="w-full"
|
||||
/>
|
||||
<div>
|
||||
<MalioInputText
|
||||
<MalioInputPassword
|
||||
v-model="form.password"
|
||||
:label="$t('zimbra.settings.password')"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
/>
|
||||
<p v-if="hasPassword && !form.password" class="mt-1 text-xs text-green-600">
|
||||
{{ $t('zimbra.settings.passwordConfigured') }}
|
||||
|
||||
@@ -78,11 +78,17 @@
|
||||
class="text-blue-500"
|
||||
size="14"
|
||||
/>
|
||||
<Icon
|
||||
v-if="task.collaborators?.length"
|
||||
name="mdi:account-group"
|
||||
class="ml-auto h-4 w-4 text-neutral-400"
|
||||
:title="task.collaborators.map(c => c.username).join(', ')"
|
||||
/>
|
||||
<UserAvatar
|
||||
v-if="task.assignee"
|
||||
:user="task.assignee"
|
||||
size="xs"
|
||||
class="ml-auto"
|
||||
:class="task.collaborators?.length ? '' : 'ml-auto'"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
|
||||
@@ -86,17 +86,25 @@
|
||||
:button-class="isTimerOnTask ? 'shrink-0 text-[#F18619] hover:text-[#d97314]' : 'shrink-0 text-neutral-400 hover:text-primary-500'"
|
||||
@click.stop="isTimerOnTask ? timerStore.stop() : timerStore.startFromTask(task)"
|
||||
/>
|
||||
<UserAvatar
|
||||
v-if="task.assignee"
|
||||
:user="task.assignee"
|
||||
size="xs"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
|
||||
>
|
||||
<Icon name="mdi:account-outline" size="14" />
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<Icon
|
||||
v-if="task.collaborators?.length"
|
||||
name="mdi:account-group"
|
||||
class="h-4 w-4 text-neutral-400"
|
||||
:title="task.collaborators.map(c => c.username).join(', ')"
|
||||
/>
|
||||
<UserAvatar
|
||||
v-if="task.assignee"
|
||||
:user="task.assignee"
|
||||
size="xs"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
|
||||
>
|
||||
<Icon name="mdi:account-outline" size="14" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -170,11 +170,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collaborators -->
|
||||
<div v-if="collaboratorOptions.length" class="mt-5">
|
||||
<p class="mb-2 text-sm font-medium text-neutral-700">Collaborateurs</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="user in collaboratorOptions"
|
||||
:key="user.value"
|
||||
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition-all"
|
||||
:class="form.collaboratorIds.includes(user.value)
|
||||
? 'bg-primary-500 text-white shadow-sm'
|
||||
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="hidden"
|
||||
:value="user.value"
|
||||
:checked="form.collaboratorIds.includes(user.value)"
|
||||
@change="toggleCollaborator(user.value)"
|
||||
/>
|
||||
{{ user.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mt-5">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-slate-700">Description</label>
|
||||
<button
|
||||
v-if="form.description"
|
||||
type="button"
|
||||
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||
@click="showMarkdownPreview = true"
|
||||
>
|
||||
<Icon name="heroicons:eye" class="size-3.5" />
|
||||
Aperçu MD
|
||||
</button>
|
||||
</div>
|
||||
<MalioInputTextArea
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
:size="5"
|
||||
resize="vertical"
|
||||
:min-resize-height="140"
|
||||
@@ -182,6 +217,12 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MarkdownPreviewModal
|
||||
v-model="showMarkdownPreview"
|
||||
:content="form.description"
|
||||
title="Aperçu de la description"
|
||||
/>
|
||||
|
||||
<!-- Documents -->
|
||||
<TaskDocumentUpload
|
||||
v-if="isEditing && task && isAdmin"
|
||||
@@ -517,7 +558,7 @@ const isOpen = computed({
|
||||
})
|
||||
|
||||
function close() {
|
||||
if (confirmDeleteDocOpen.value || confirmDeleteOpen.value) return
|
||||
if (confirmDeleteDocOpen.value || confirmDeleteOpen.value || showMarkdownPreview.value) return
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
@@ -525,6 +566,7 @@ const isEditing = computed(() => !!props.task)
|
||||
const isSubmitting = ref(false)
|
||||
const confirmDeleteOpen = ref(false)
|
||||
const activeTab = ref<'details' | 'planning'>('details')
|
||||
const showMarkdownPreview = ref(false)
|
||||
|
||||
const giteaUrl = ref('')
|
||||
const { getSettings: getGiteaSettings } = useGiteaService()
|
||||
@@ -544,6 +586,7 @@ const form = reactive({
|
||||
effortId: null as number | null,
|
||||
priorityId: null as number | null,
|
||||
assigneeId: null as number | null,
|
||||
collaboratorIds: [] as number[],
|
||||
groupId: null as number | null,
|
||||
tagIds: [] as number[],
|
||||
clientTicketId: null as number | null,
|
||||
@@ -586,6 +629,18 @@ const userOptions = computed(() =>
|
||||
props.users.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
const collaboratorOptions = computed(() =>
|
||||
props.users
|
||||
.filter(u => u.id !== form.assigneeId)
|
||||
.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
watch(() => form.assigneeId, (newAssigneeId) => {
|
||||
if (newAssigneeId) {
|
||||
form.collaboratorIds = form.collaboratorIds.filter(id => id !== newAssigneeId)
|
||||
}
|
||||
})
|
||||
|
||||
const groupOptions = computed(() => {
|
||||
let filtered = props.groups.filter(g => !g.archived)
|
||||
if (showProjectSelect.value && form.projectId) {
|
||||
@@ -624,6 +679,12 @@ function toggleTag(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCollaborator(userId: number) {
|
||||
const idx = form.collaboratorIds.indexOf(userId)
|
||||
if (idx >= 0) form.collaboratorIds.splice(idx, 1)
|
||||
else form.collaboratorIds.push(userId)
|
||||
}
|
||||
|
||||
const weekDays = computed(() => [
|
||||
{ value: 'monday', label: t('tasks.planning.days.mon') },
|
||||
{ value: 'tuesday', label: t('tasks.planning.days.tue') },
|
||||
@@ -648,6 +709,7 @@ function populateForm(task: Task | null) {
|
||||
form.effortId = task.effort?.id ?? null
|
||||
form.priorityId = task.priority?.id ?? null
|
||||
form.assigneeId = task.assignee?.id ?? null
|
||||
form.collaboratorIds = task.collaborators?.map(c => c.id) ?? []
|
||||
form.groupId = task.group?.id ?? null
|
||||
form.tagIds = task.tags.map(t => t.id)
|
||||
form.clientTicketId = task.clientTicket?.id ?? null
|
||||
@@ -694,6 +756,7 @@ function populateForm(task: Task | null) {
|
||||
form.effortId = null
|
||||
form.priorityId = null
|
||||
form.assigneeId = null
|
||||
form.collaboratorIds = []
|
||||
form.groupId = null
|
||||
form.tagIds = []
|
||||
form.clientTicketId = null
|
||||
@@ -906,6 +969,7 @@ async function handleSubmit() {
|
||||
effort: form.effortId ? `/api/task_efforts/${form.effortId}` : null,
|
||||
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
|
||||
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
|
||||
collaborators: form.collaboratorIds.map(id => `/api/users/${id}`),
|
||||
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
|
||||
project: `/api/projects/${resolvedProjectId.value}`,
|
||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||
|
||||
@@ -10,15 +10,7 @@
|
||||
@click="ui.openMobileSidebar()"
|
||||
/>
|
||||
<div class="hidden items-center gap-2 lg:flex">
|
||||
<h1 class="text-lg font-bold tracking-tight">{{ appTitle }}</h1>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:swap-horizontal"
|
||||
:aria-label="appTitle === 'NeauTime' ? 'Switch to Lesstime' : 'Switch to NeauTime'"
|
||||
variant="ghost"
|
||||
icon-size="18"
|
||||
button-class="text-white/60 hover:bg-primary-600 hover:text-white"
|
||||
@click="toggleTitle"
|
||||
/>
|
||||
<h1 class="text-lg font-bold tracking-tight">Lesstime</h1>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
|
||||
<MalioButtonIcon
|
||||
@@ -66,13 +58,6 @@ defineProps<{
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
|
||||
const appTitle = ref(localStorage.getItem('appTitle') || 'NeauTime')
|
||||
|
||||
function toggleTitle() {
|
||||
appTitle.value = appTitle.value === 'NeauTime' ? 'Lesstime' : 'NeauTime'
|
||||
localStorage.setItem('appTitle', appTitle.value)
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await auth.logout()
|
||||
await navigateTo('/login')
|
||||
|
||||
75
frontend/components/ui/MarkdownPreviewModal.vue
Normal file
75
frontend/components/ui/MarkdownPreviewModal.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="md-preview" appear>
|
||||
<div v-if="modelValue" class="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
|
||||
<!-- Modal -->
|
||||
<div
|
||||
class="relative z-10 flex w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
|
||||
style="max-height: min(80vh, 700px)"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-6 py-4">
|
||||
<h3 class="text-lg font-semibold text-slate-800">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<button
|
||||
class="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
|
||||
@click="emit('update:modelValue', false)"
|
||||
>
|
||||
<Icon name="heroicons:x-mark" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="overflow-y-auto px-6 py-4">
|
||||
<div
|
||||
v-if="content"
|
||||
class="prose prose-slate max-w-none prose-headings:font-semibold prose-a:text-blue-600 prose-code:rounded prose-code:bg-slate-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:text-sm prose-code:before:content-none prose-code:after:content-none prose-pre:bg-slate-900 prose-pre:text-slate-100"
|
||||
v-html="renderedHtml"
|
||||
/>
|
||||
<p v-else class="text-sm italic text-slate-400">
|
||||
Aucune description
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { marked } from 'marked'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
content: string
|
||||
title?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const renderedHtml = computed(() => {
|
||||
if (!props.content) return ''
|
||||
return marked.parse(props.content, { async: false }) as string
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.md-preview-enter-active,
|
||||
.md-preview-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.md-preview-enter-from,
|
||||
.md-preview-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -8,12 +8,11 @@
|
||||
:error="touched.username && !form.username.trim() ? 'Le nom est requis' : ''"
|
||||
@blur="touched.username = true"
|
||||
/>
|
||||
<MalioInputText
|
||||
<MalioInputPassword
|
||||
v-model="form.password"
|
||||
label="Mot de passe"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
:placeholder="isEditing ? 'Laisser vide pour ne pas changer' : ''"
|
||||
:hint="isEditing ? 'Laisser vide pour ne pas changer' : ''"
|
||||
:error="touched.password && !isEditing && !form.password ? 'Le mot de passe est requis' : ''"
|
||||
@blur="touched.password = true"
|
||||
/>
|
||||
|
||||
47
frontend/package-lock.json
generated
47
frontend/package-lock.json
generated
@@ -7,13 +7,15 @@
|
||||
"name": "nuxt-app",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.2.0",
|
||||
"@malio/layer-ui": "^1.2.3",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@vuepic/vue-datepicker": "^12.1.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"marked": "^18.0.0",
|
||||
"nuxt": "^4.3.1",
|
||||
"nuxt-toast": "^1.4.0",
|
||||
"pinia": "^3.0.4",
|
||||
@@ -2212,9 +2214,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@malio/layer-ui": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.2.0/layer-ui-1.2.0.tgz",
|
||||
"integrity": "sha512-/D/p7Tz5t8xsZ+qL4kwBs2XXA/yNJpwF5C8pbSrz06Z8Je/Yut2J4KT1YpPHcfyFFE3TB8TpV0Okg/29aN6Ggg==",
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.2.3/layer-ui-1.2.3.tgz",
|
||||
"integrity": "sha512-5nRnBzRkXfs3PfKwKl6sH2ikrmSK7lTifcd0TX1QZP3rFRVRTgcT6mrsrpsbR9PwI27OeCNm0X6d0Ii92Rq7Yg==",
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -5301,6 +5303,31 @@
|
||||
"integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/@tailwindcss/typography": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
||||
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss-selector-parser": "6.0.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
@@ -9705,6 +9732,18 @@
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.0",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.0.tgz",
|
||||
"integrity": "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/maska": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/maska/-/maska-3.2.0.tgz",
|
||||
|
||||
@@ -11,13 +11,15 @@
|
||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.2.0",
|
||||
"@malio/layer-ui": "^1.2.3",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@vuepic/vue-datepicker": "^12.1.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"marked": "^18.0.0",
|
||||
"nuxt": "^4.3.1",
|
||||
"nuxt-toast": "^1.4.0",
|
||||
"pinia": "^3.0.4",
|
||||
|
||||
@@ -172,7 +172,10 @@ const totalHoursThisWeek = computed(() =>
|
||||
)
|
||||
|
||||
const myTasks = computed(() =>
|
||||
tasks.value.filter(t => t.assignee?.id === auth.user?.id)
|
||||
tasks.value.filter(t =>
|
||||
t.assignee?.id === auth.user?.id
|
||||
|| t.collaborators?.some(c => c.id === auth.user?.id)
|
||||
)
|
||||
)
|
||||
|
||||
const myTasksDone = computed(() =>
|
||||
|
||||
@@ -17,24 +17,18 @@
|
||||
v-model="username"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-semibold text-neutral-700" for="password">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputPassword
|
||||
v-model="password"
|
||||
label="Mot de passe"
|
||||
autocomplete="current-password"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<MalioButton
|
||||
label="Se connecter"
|
||||
button-class="w-full"
|
||||
type="submit"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
<p class="font-bold">v{{ version }}</p>
|
||||
</form>
|
||||
|
||||
@@ -51,8 +51,9 @@ const selectedEffortId = ref<number | null>(null)
|
||||
const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
|
||||
|
||||
// Sort
|
||||
type SortOption = 'default' | 'deadline' | 'scheduledStart'
|
||||
const sortBy = ref<SortOption>('default')
|
||||
const SORT_DEADLINE = 1
|
||||
const SORT_SCHEDULED = 2
|
||||
const sortById = ref<number | null>(null)
|
||||
|
||||
// View toggle
|
||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||
@@ -106,6 +107,11 @@ const assigneeOptions = computed(() =>
|
||||
users.value.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
const sortOptions = computed(() => [
|
||||
{ label: t('myTasks.sortDeadline'), value: SORT_DEADLINE },
|
||||
{ label: t('myTasks.sortScheduledStart'), value: SORT_SCHEDULED },
|
||||
])
|
||||
|
||||
// Kanban helpers
|
||||
const sortedStatuses = computed(() =>
|
||||
[...statuses.value].sort((a, b) => a.position - b.position)
|
||||
@@ -140,33 +146,43 @@ async function loadReferenceData() {
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
const params: Record<string, string | number | boolean | string[]> = {
|
||||
const baseParams: Record<string, string | number | boolean | string[]> = {
|
||||
archived: false,
|
||||
}
|
||||
if (selectedAssigneeId.value) {
|
||||
params.assignee = `/api/users/${selectedAssigneeId.value}`
|
||||
}
|
||||
if (selectedProjectId.value) {
|
||||
params.project = `/api/projects/${selectedProjectId.value}`
|
||||
baseParams.project = `/api/projects/${selectedProjectId.value}`
|
||||
}
|
||||
if (selectedGroupId.value) {
|
||||
params.group = `/api/task_groups/${selectedGroupId.value}`
|
||||
baseParams.group = `/api/task_groups/${selectedGroupId.value}`
|
||||
}
|
||||
if (selectedPriorityId.value) {
|
||||
params.priority = `/api/task_priorities/${selectedPriorityId.value}`
|
||||
baseParams.priority = `/api/task_priorities/${selectedPriorityId.value}`
|
||||
}
|
||||
if (selectedEffortId.value) {
|
||||
params.effort = `/api/task_efforts/${selectedEffortId.value}`
|
||||
baseParams.effort = `/api/task_efforts/${selectedEffortId.value}`
|
||||
}
|
||||
if (selectedTagId.value) {
|
||||
params['tags[]'] = `/api/task_tags/${selectedTagId.value}`
|
||||
baseParams['tags[]'] = `/api/task_tags/${selectedTagId.value}`
|
||||
}
|
||||
if (sortBy.value === 'deadline') {
|
||||
params['order[deadline]'] = 'asc'
|
||||
} else if (sortBy.value === 'scheduledStart') {
|
||||
params['order[scheduledStart]'] = 'asc'
|
||||
if (sortById.value === SORT_DEADLINE) {
|
||||
baseParams['order[deadline]'] = 'asc'
|
||||
} else if (sortById.value === SORT_SCHEDULED) {
|
||||
baseParams['order[scheduledStart]'] = 'asc'
|
||||
}
|
||||
|
||||
if (selectedAssigneeId.value) {
|
||||
const userIri = `/api/users/${selectedAssigneeId.value}`
|
||||
const [assigneeTasks, collabTasks] = await Promise.all([
|
||||
taskService.getFiltered({ ...baseParams, assignee: userIri }),
|
||||
taskService.getFiltered({ ...baseParams, 'collaborators[]': userIri }),
|
||||
])
|
||||
const map = new Map<number, Task>()
|
||||
for (const t of assigneeTasks) map.set(t.id, t)
|
||||
for (const t of collabTasks) map.set(t.id, t)
|
||||
tasks.value = [...map.values()].sort((a, b) => b.id - a.id)
|
||||
} else {
|
||||
tasks.value = await taskService.getFiltered(baseParams)
|
||||
}
|
||||
tasks.value = await taskService.getFiltered(params)
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
@@ -180,7 +196,7 @@ async function loadAll() {
|
||||
|
||||
// Watch filters and sort to reload tasks
|
||||
watch(
|
||||
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId, sortBy],
|
||||
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId, sortById],
|
||||
() => { loadTasks() },
|
||||
)
|
||||
|
||||
@@ -400,17 +416,15 @@ onMounted(async () => {
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-xs font-semibold text-neutral-500">{{ $t('myTasks.sortBy') }}</span>
|
||||
<select
|
||||
v-model="sortBy"
|
||||
class="rounded-lg border border-neutral-300 bg-white px-2 py-1.5 text-sm text-neutral-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="default">{{ $t('myTasks.sortDefault') }}</option>
|
||||
<option value="deadline">{{ $t('myTasks.sortDeadline') }}</option>
|
||||
<option value="scheduledStart">{{ $t('myTasks.sortScheduledStart') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<MalioSelect
|
||||
v-model="sortById"
|
||||
:options="sortOptions"
|
||||
:label="$t('myTasks.sortBy')"
|
||||
:empty-option-label="$t('myTasks.sortDefault')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -298,7 +298,10 @@ const filteredTasks = computed(() => {
|
||||
result = result.filter(t => t.tags?.some(tag => tag.id === selectedTagId.value))
|
||||
}
|
||||
if (selectedAssigneeId.value) {
|
||||
result = result.filter(t => t.assignee?.id === selectedAssigneeId.value)
|
||||
result = result.filter(t =>
|
||||
t.assignee?.id === selectedAssigneeId.value
|
||||
|| t.collaborators?.some(c => c.id === selectedAssigneeId.value)
|
||||
)
|
||||
}
|
||||
if (selectedStatusId.value) {
|
||||
result = result.filter(t => t.status?.id === selectedStatusId.value)
|
||||
|
||||
@@ -17,6 +17,7 @@ export type Task = {
|
||||
effort: TaskEffort | null
|
||||
priority: TaskPriority | null
|
||||
assignee: UserData | null
|
||||
collaborators: UserData[]
|
||||
group: TaskGroup | null
|
||||
project: Project | null
|
||||
tags: TaskTag[]
|
||||
@@ -55,6 +56,7 @@ export type TaskWrite = {
|
||||
effort: string | null
|
||||
priority: string | null
|
||||
assignee: string | null
|
||||
collaborators?: string[]
|
||||
group: string | null
|
||||
project: string
|
||||
tags: string[]
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type {Config} from 'tailwindcss'
|
||||
import typography from '@tailwindcss/typography'
|
||||
|
||||
export default <Partial<Config>>{
|
||||
darkMode: 'class',
|
||||
plugins: [typography],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
|
||||
@@ -61,6 +61,7 @@ RUN rm -f /etc/nginx/sites-enabled/default
|
||||
# Configs
|
||||
COPY infra/prod/supervisord.conf /etc/supervisor/conf.d/app.conf
|
||||
COPY infra/prod/nginx.conf /etc/nginx/sites-enabled/lesstime.conf
|
||||
COPY infra/prod/maintenance.html /var/www/html/public/maintenance.html
|
||||
|
||||
# Backend from stage 1
|
||||
COPY --from=backend-build /app /var/www/html
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Usage: ./infra/prod/deploy-release.sh v0.1.0
|
||||
# Requires: curl, tar, (optional) rsync
|
||||
#
|
||||
# Auth token: set RELEASE_TOKEN env var or create /etc/lesstime-release-token
|
||||
umask 002
|
||||
|
||||
TAG="${1:-}"
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "Usage: $0 v0.1.0" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPO_OWNER="MALIO-DEV"
|
||||
REPO_NAME="Lesstime"
|
||||
GITEA_API="https://gitea.malio.fr/api/v1"
|
||||
DEPLOY_DIR="/var/www/lesstime"
|
||||
|
||||
if [ -f /etc/lesstime-release-token ] && [ -z "${RELEASE_TOKEN:-}" ]; then
|
||||
RELEASE_TOKEN="$(cat /etc/lesstime-release-token)"
|
||||
fi
|
||||
|
||||
tmp_dir="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "$tmp_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
release_json="$tmp_dir/release.json"
|
||||
curl_opts=(-sS)
|
||||
if [ -n "${RELEASE_TOKEN:-}" ]; then
|
||||
curl_opts+=(-H "Authorization: token ${RELEASE_TOKEN}")
|
||||
fi
|
||||
curl "${curl_opts[@]}" \
|
||||
"${GITEA_API}/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}" \
|
||||
-o "$release_json"
|
||||
|
||||
asset_url="$(python3 - "$release_json" <<'PY'
|
||||
import json, sys
|
||||
data = json.load(open(sys.argv[1], 'r'))
|
||||
assets = data.get("assets", [])
|
||||
for a in assets:
|
||||
name = a.get("name", "")
|
||||
if name.startswith("lesstime-") and name.endswith(".tar.gz"):
|
||||
print(a.get("browser_download_url", ""))
|
||||
break
|
||||
PY
|
||||
)"
|
||||
|
||||
if [ -z "$asset_url" ]; then
|
||||
echo "Release asset not found for tag ${TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
archive="$tmp_dir/artefact.tar.gz"
|
||||
curl "${curl_opts[@]}" -L "$asset_url" -o "$archive"
|
||||
|
||||
tar -xzf "$archive" -C "$tmp_dir"
|
||||
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
rsync -a --delete --no-perms --no-owner --no-group \
|
||||
--exclude ".env" \
|
||||
--exclude ".env.local" \
|
||||
--exclude "config/jwt" \
|
||||
--exclude "var" \
|
||||
"$tmp_dir"/ "$DEPLOY_DIR"/
|
||||
else
|
||||
cp -a "$tmp_dir"/. "$DEPLOY_DIR"/
|
||||
fi
|
||||
|
||||
# Ensure Nginx can traverse the deploy path.
|
||||
chmod o+rx "$(dirname "$DEPLOY_DIR")" "$DEPLOY_DIR" 2>/dev/null || true
|
||||
|
||||
# Create frontend/dist symlink if needed (nginx serves from frontend/dist)
|
||||
if [ -d "${DEPLOY_DIR}/frontend/.output/public" ] && [ ! -L "${DEPLOY_DIR}/frontend/dist" ]; then
|
||||
ln -sfn "${DEPLOY_DIR}/frontend/.output/public" "${DEPLOY_DIR}/frontend/dist"
|
||||
fi
|
||||
|
||||
echo "Release ${TAG} deployed to ${DEPLOY_DIR}"
|
||||
|
||||
# Ensure var/log exists and is writable by PHP (www-data)
|
||||
mkdir -p "${DEPLOY_DIR}/var/log"
|
||||
chown www-data:www-data "${DEPLOY_DIR}/var/log"
|
||||
chmod 775 "${DEPLOY_DIR}/var/log"
|
||||
|
||||
if [ -f "${DEPLOY_DIR}/.env.local" ]; then
|
||||
echo "Clearing cache..."
|
||||
php "${DEPLOY_DIR}/bin/console" cache:clear --env=prod --no-debug
|
||||
|
||||
echo "Running migrations (if any)..."
|
||||
php "${DEPLOY_DIR}/bin/console" doctrine:migrations:migrate --no-interaction --env=prod
|
||||
else
|
||||
echo "Skip post-deploy: ${DEPLOY_DIR}/.env.local not found" >&2
|
||||
fi
|
||||
@@ -8,6 +8,9 @@ export LESSTIME_IMAGE_TAG="$TAG"
|
||||
|
||||
echo "==> Deploying lesstime:${TAG}..."
|
||||
|
||||
echo "==> Enabling maintenance mode..."
|
||||
touch maintenance.on
|
||||
|
||||
echo "==> Pulling image..."
|
||||
sudo docker compose pull
|
||||
|
||||
@@ -17,6 +20,10 @@ sudo docker compose up -d
|
||||
echo "==> Waiting for container to be ready..."
|
||||
sleep 3
|
||||
|
||||
echo "==> Extracting maintenance page..."
|
||||
mkdir -p public
|
||||
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
|
||||
|
||||
echo "==> Running migrations..."
|
||||
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
@@ -24,5 +31,8 @@ echo "==> Clearing cache..."
|
||||
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||
|
||||
echo "==> Disabling maintenance mode..."
|
||||
rm -f maintenance.on
|
||||
|
||||
VERSION=$(sudo docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
|
||||
echo "==> Deployed v${VERSION}"
|
||||
|
||||
@@ -8,6 +8,10 @@ services:
|
||||
volumes:
|
||||
- ./config/jwt:/var/www/html/config/jwt:ro
|
||||
- ./uploads:/var/www/html/var/uploads
|
||||
- lesstime_logs:/var/www/html/var/log
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
lesstime_logs:
|
||||
|
||||
49
infra/prod/maintenance.html
Normal file
49
infra/prod/maintenance.html
Normal file
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Maintenance en cours</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #f3f4f6;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.10);
|
||||
padding: 48px 40px;
|
||||
max-width: 480px;
|
||||
text-align: center;
|
||||
}
|
||||
.icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
h1 {
|
||||
color: #1f2937;
|
||||
font-size: 24px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
p {
|
||||
color: #6b7280;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">🛠</div>
|
||||
<h1>Maintenance en cours</h1>
|
||||
<p>L'application est temporairement indisponible pour mise à jour. Elle sera de retour dans quelques instants.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,50 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name project.malio-dev.fr;
|
||||
|
||||
root /var/www/lesstime/frontend/.output/public;
|
||||
index index.html;
|
||||
|
||||
client_max_body_size 55m;
|
||||
|
||||
location ^~ /api/ {
|
||||
root /var/www/lesstime/public;
|
||||
try_files $uri /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ^~ /bundles/ {
|
||||
root /var/www/lesstime/public;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /api/login_check {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/lesstime/public/index.php;
|
||||
fastcgi_param DOCUMENT_ROOT /var/www/lesstime/public;
|
||||
fastcgi_param SCRIPT_NAME /index.php;
|
||||
fastcgi_param PATH_INFO /login_check;
|
||||
fastcgi_param REQUEST_URI /login_check;
|
||||
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
|
||||
}
|
||||
|
||||
location ^~ /_mcp {
|
||||
root /var/www/lesstime/public;
|
||||
try_files $uri /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ ^/index\.php(/|$) {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/lesstime/public/index.php;
|
||||
fastcgi_param DOCUMENT_ROOT /var/www/lesstime/public;
|
||||
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,25 @@ server {
|
||||
listen [::]:80;
|
||||
server_name project.malio-dev.fr;
|
||||
|
||||
root /var/www/lesstime/public;
|
||||
|
||||
# Maintenance mode
|
||||
if (-f /var/www/lesstime/maintenance.on) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
error_page 503 @maintenance;
|
||||
|
||||
location @maintenance {
|
||||
rewrite ^(.*)$ /maintenance.html break;
|
||||
}
|
||||
|
||||
location = /maintenance.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
@@ -2,6 +2,23 @@ server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Maintenance mode
|
||||
if (-f /var/www/html/maintenance.on) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
error_page 503 @maintenance;
|
||||
|
||||
location @maintenance {
|
||||
root /var/www/html/public;
|
||||
rewrite ^(.*)$ /maintenance.html break;
|
||||
}
|
||||
|
||||
location = /maintenance.html {
|
||||
root /var/www/html/public;
|
||||
internal;
|
||||
}
|
||||
|
||||
root /var/www/html/frontend/.output/public;
|
||||
index index.html;
|
||||
|
||||
|
||||
37
migrations/Version20260409075411.php
Normal file
37
migrations/Version20260409075411.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260409075411 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE task_collaborator (task_id INT NOT NULL, user_id INT NOT NULL, PRIMARY KEY (task_id, user_id))');
|
||||
$this->addSql('CREATE INDEX IDX_A8FC6C518DB60186 ON task_collaborator (task_id)');
|
||||
$this->addSql('CREATE INDEX IDX_A8FC6C51A76ED395 ON task_collaborator (user_id)');
|
||||
$this->addSql('ALTER TABLE task_collaborator ADD CONSTRAINT FK_A8FC6C518DB60186 FOREIGN KEY (task_id) REFERENCES task (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE task_collaborator ADD CONSTRAINT FK_A8FC6C51A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE task_collaborator DROP CONSTRAINT FK_A8FC6C518DB60186');
|
||||
$this->addSql('ALTER TABLE task_collaborator DROP CONSTRAINT FK_A8FC6C51A76ED395');
|
||||
$this->addSql('DROP TABLE task_collaborator');
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
denormalizationContext: ['groups' => ['task:write']],
|
||||
order: ['id' => 'DESC'],
|
||||
)]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact', 'assignee' => 'exact', 'priority' => 'exact', 'effort' => 'exact', 'tags' => 'exact', 'status' => 'exact'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact', 'assignee' => 'exact', 'collaborators' => 'exact', 'priority' => 'exact', 'effort' => 'exact', 'tags' => 'exact', 'status' => 'exact'])]
|
||||
#[ApiFilter(DateFilter::class, properties: ['scheduledStart', 'scheduledEnd', 'deadline'])]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['archived', 'syncToCalendar'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['scheduledStart', 'deadline'])]
|
||||
@@ -85,6 +85,16 @@ class Task
|
||||
#[Groups(['task:read', 'task:write'])]
|
||||
private ?User $assignee = null;
|
||||
|
||||
/** @var Collection<int, User> */
|
||||
#[ORM\ManyToMany(targetEntity: User::class)]
|
||||
#[ORM\JoinTable(
|
||||
name: 'task_collaborator',
|
||||
joinColumns: [new ORM\JoinColumn(name: 'task_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
|
||||
inverseJoinColumns: [new ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
|
||||
)]
|
||||
#[Groups(['task:read', 'task:write'])]
|
||||
private Collection $collaborators;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: TaskGroup::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['task:read', 'task:write'])]
|
||||
@@ -152,8 +162,9 @@ class Task
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->tags = new ArrayCollection();
|
||||
$this->documents = new ArrayCollection();
|
||||
$this->tags = new ArrayCollection();
|
||||
$this->documents = new ArrayCollection();
|
||||
$this->collaborators = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
@@ -245,6 +256,28 @@ class Task
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, User> */
|
||||
public function getCollaborators(): Collection
|
||||
{
|
||||
return $this->collaborators;
|
||||
}
|
||||
|
||||
public function addCollaborator(User $user): static
|
||||
{
|
||||
if (!$this->collaborators->contains($user)) {
|
||||
$this->collaborators->add($user);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeCollaborator(User $user): static
|
||||
{
|
||||
$this->collaborators->removeElement($user);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getGroup(): ?TaskGroup
|
||||
{
|
||||
return $this->group;
|
||||
@@ -434,4 +467,15 @@ class Task
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
#[Assert\Callback]
|
||||
public function validateCollaborators(ExecutionContextInterface $context): void
|
||||
{
|
||||
if (null !== $this->assignee && $this->collaborators->contains($this->assignee)) {
|
||||
$context->buildViolation('The assignee cannot also be a collaborator.')
|
||||
->atPath('collaborators')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,19 @@ final class Serializer
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, User> $users
|
||||
*
|
||||
* @return list<array{id: ?int, username: ?string}>
|
||||
*/
|
||||
public static function users(Collection $users): array
|
||||
{
|
||||
return $users->map(fn (User $u) => [
|
||||
'id' => $u->getId(),
|
||||
'username' => $u->getUsername(),
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null|array{id: ?int, title: ?string, color: ?string}
|
||||
*/
|
||||
|
||||
@@ -51,6 +51,7 @@ class CreateTaskTool
|
||||
?int $assigneeId = null,
|
||||
?int $groupId = null,
|
||||
?array $tagIds = null,
|
||||
?array $collaboratorIds = null,
|
||||
?string $scheduledStart = null,
|
||||
?string $scheduledEnd = null,
|
||||
?string $deadline = null,
|
||||
@@ -116,6 +117,18 @@ class CreateTaskTool
|
||||
$task->addTag($tag);
|
||||
}
|
||||
}
|
||||
if (null !== $collaboratorIds) {
|
||||
foreach ($collaboratorIds as $collaboratorId) {
|
||||
$collaborator = $this->userRepository->find($collaboratorId);
|
||||
if (null === $collaborator) {
|
||||
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $collaboratorId));
|
||||
}
|
||||
if (null !== $assigneeId && $collaboratorId === $assigneeId) {
|
||||
throw new InvalidArgumentException('A collaborator cannot be the assignee.');
|
||||
}
|
||||
$task->addCollaborator($collaborator);
|
||||
}
|
||||
}
|
||||
if (null !== $scheduledStart) {
|
||||
$task->setScheduledStart(new DateTimeImmutable($scheduledStart));
|
||||
}
|
||||
@@ -147,6 +160,7 @@ class CreateTaskTool
|
||||
'priority' => Serializer::priority($task->getPriority()),
|
||||
'effort' => Serializer::effort($task->getEffort()),
|
||||
'assignee' => Serializer::user($task->getAssignee()),
|
||||
'collaborators' => Serializer::users($task->getCollaborators()),
|
||||
'group' => Serializer::groupRef($task->getGroup()),
|
||||
'project' => Serializer::projectRef($project),
|
||||
'tags' => Serializer::tags($task->getTags()),
|
||||
|
||||
@@ -34,19 +34,20 @@ class GetTaskTool
|
||||
}
|
||||
|
||||
return json_encode([
|
||||
'id' => $task->getId(),
|
||||
'number' => $task->getNumber(),
|
||||
'title' => $task->getTitle(),
|
||||
'description' => $task->getDescription(),
|
||||
'status' => Serializer::statusFull($task->getStatus()),
|
||||
'priority' => Serializer::priority($task->getPriority()),
|
||||
'effort' => Serializer::effort($task->getEffort()),
|
||||
'assignee' => Serializer::user($task->getAssignee()),
|
||||
'group' => Serializer::group($task->getGroup()),
|
||||
'project' => Serializer::projectRef($task->getProject()),
|
||||
'tags' => Serializer::tagsWithColor($task->getTags()),
|
||||
'documents' => Serializer::documents($task->getDocuments()),
|
||||
'archived' => $task->isArchived(),
|
||||
'id' => $task->getId(),
|
||||
'number' => $task->getNumber(),
|
||||
'title' => $task->getTitle(),
|
||||
'description' => $task->getDescription(),
|
||||
'status' => Serializer::statusFull($task->getStatus()),
|
||||
'priority' => Serializer::priority($task->getPriority()),
|
||||
'effort' => Serializer::effort($task->getEffort()),
|
||||
'assignee' => Serializer::user($task->getAssignee()),
|
||||
'collaborators' => Serializer::users($task->getCollaborators()),
|
||||
'group' => Serializer::group($task->getGroup()),
|
||||
'project' => Serializer::projectRef($task->getProject()),
|
||||
'tags' => Serializer::tagsWithColor($task->getTags()),
|
||||
'documents' => Serializer::documents($task->getDocuments()),
|
||||
'archived' => $task->isArchived(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
#[McpTool(name: 'list-tasks', description: 'List tasks with optional filters by project, status, assignee, priority, group, tags, and archive state. Returns max 100 results by default, use filters to narrow down.')]
|
||||
#[McpTool(name: 'list-tasks', description: 'List tasks with optional filters by project, status, assignee, collaborator, priority, group, tags, and archive state. Returns max 100 results by default, use filters to narrow down.')]
|
||||
class ListTasksTool
|
||||
{
|
||||
public function __construct(
|
||||
@@ -22,6 +22,7 @@ class ListTasksTool
|
||||
?int $projectId = null,
|
||||
?int $statusId = null,
|
||||
?int $assigneeId = null,
|
||||
?int $collaboratorId = null,
|
||||
?int $priorityId = null,
|
||||
?int $groupId = null,
|
||||
?array $tagIds = null,
|
||||
@@ -38,6 +39,7 @@ class ListTasksTool
|
||||
->leftJoin('t.status', 's')->addSelect('s')
|
||||
->leftJoin('t.priority', 'p')->addSelect('p')
|
||||
->leftJoin('t.assignee', 'a')->addSelect('a')
|
||||
->leftJoin('t.collaborators', 'collab')->addSelect('collab')
|
||||
->leftJoin('t.project', 'pr')->addSelect('pr')
|
||||
->leftJoin('t.effort', 'e')->addSelect('e')
|
||||
->leftJoin('t.group', 'g')->addSelect('g')
|
||||
@@ -57,6 +59,9 @@ class ListTasksTool
|
||||
if (null !== $assigneeId) {
|
||||
$qb->andWhere('a.id = :assigneeId')->setParameter('assigneeId', $assigneeId);
|
||||
}
|
||||
if (null !== $collaboratorId) {
|
||||
$qb->andWhere('collab.id = :collaboratorId')->setParameter('collaboratorId', $collaboratorId);
|
||||
}
|
||||
if (null !== $priorityId) {
|
||||
$qb->andWhere('p.id = :priorityId')->setParameter('priorityId', $priorityId);
|
||||
}
|
||||
@@ -75,17 +80,18 @@ class ListTasksTool
|
||||
}
|
||||
|
||||
return json_encode(array_map(fn ($task) => [
|
||||
'id' => $task->getId(),
|
||||
'number' => $task->getNumber(),
|
||||
'title' => $task->getTitle(),
|
||||
'status' => Serializer::status($task->getStatus()),
|
||||
'priority' => Serializer::priority($task->getPriority()),
|
||||
'assignee' => Serializer::user($task->getAssignee()),
|
||||
'effort' => Serializer::effort($task->getEffort()),
|
||||
'group' => Serializer::groupRef($task->getGroup()),
|
||||
'project' => Serializer::projectRef($task->getProject()),
|
||||
'tags' => Serializer::tags($task->getTags()),
|
||||
'archived' => $task->isArchived(),
|
||||
'id' => $task->getId(),
|
||||
'number' => $task->getNumber(),
|
||||
'title' => $task->getTitle(),
|
||||
'status' => Serializer::status($task->getStatus()),
|
||||
'priority' => Serializer::priority($task->getPriority()),
|
||||
'assignee' => Serializer::user($task->getAssignee()),
|
||||
'collaborators' => Serializer::users($task->getCollaborators()),
|
||||
'effort' => Serializer::effort($task->getEffort()),
|
||||
'group' => Serializer::groupRef($task->getGroup()),
|
||||
'project' => Serializer::projectRef($task->getProject()),
|
||||
'tags' => Serializer::tags($task->getTags()),
|
||||
'archived' => $task->isArchived(),
|
||||
], array_values($tasks)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ class UpdateTaskTool
|
||||
?int $assigneeId = null,
|
||||
?int $groupId = null,
|
||||
?array $tagIds = null,
|
||||
?array $collaboratorIds = null,
|
||||
?bool $archived = null,
|
||||
?string $scheduledStart = null,
|
||||
?string $scheduledEnd = null,
|
||||
@@ -118,6 +119,22 @@ class UpdateTaskTool
|
||||
$task->addTag($tag);
|
||||
}
|
||||
}
|
||||
if (null !== $collaboratorIds) {
|
||||
foreach ($task->getCollaborators()->toArray() as $existing) {
|
||||
$task->removeCollaborator($existing);
|
||||
}
|
||||
$assignee = $task->getAssignee();
|
||||
foreach ($collaboratorIds as $collaboratorId) {
|
||||
$collaborator = $this->userRepository->find($collaboratorId);
|
||||
if (null === $collaborator) {
|
||||
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $collaboratorId));
|
||||
}
|
||||
if (null !== $assignee && $collaborator->getId() === $assignee->getId()) {
|
||||
throw new InvalidArgumentException('A collaborator cannot be the assignee.');
|
||||
}
|
||||
$task->addCollaborator($collaborator);
|
||||
}
|
||||
}
|
||||
if (null !== $archived) {
|
||||
$task->setArchived($archived);
|
||||
}
|
||||
@@ -147,6 +164,7 @@ class UpdateTaskTool
|
||||
'priority' => Serializer::priority($task->getPriority()),
|
||||
'effort' => Serializer::effort($task->getEffort()),
|
||||
'assignee' => Serializer::user($task->getAssignee()),
|
||||
'collaborators' => Serializer::users($task->getCollaborators()),
|
||||
'group' => Serializer::groupRef($task->getGroup()),
|
||||
'project' => Serializer::projectRef($task->getProject()),
|
||||
'tags' => Serializer::tags($task->getTags()),
|
||||
|
||||
Reference in New Issue
Block a user