feat(TaskManagement): implement centralized readonly field logic for task synchronization

- Added functionality to determine readonly fields based on task source (Jira, TFS) and status.
- Updated EditTaskForm and TaskBasicFields components to utilize readonly fields for better user experience.
- Introduced buildSyncUpdateData function to manage field preservation during synchronization.
- Enhanced tests for readonly field logic to ensure correct behavior across different scenarios.
This commit is contained in:
Julien Froidefond
2025-11-21 10:40:21 +01:00
parent b8256a18b6
commit 31f9855a3c
8 changed files with 669 additions and 67 deletions

View File

@@ -143,6 +143,8 @@ export function EditTaskForm({
}
errors={errors}
loading={loading}
readonlyFields={task.readonlyFields || []}
source={task.source}
/>
<TaskJiraInfo task={task} />

View File

@@ -19,6 +19,8 @@ interface TaskBasicFieldsProps {
onDueDateChange: (date?: Date) => void;
errors: Record<string, string>;
loading: boolean;
readonlyFields?: string[];
source?: string; // Source de la tâche pour déterminer le message (jira, tfs, etc.)
}
export function TaskBasicFields({
@@ -34,31 +36,71 @@ export function TaskBasicFields({
onDueDateChange,
errors,
loading,
readonlyFields = [],
source,
}: TaskBasicFieldsProps) {
const isTitleReadonly = readonlyFields.includes('title');
const isDescriptionReadonly = readonlyFields.includes('description');
const isStatusReadonly = readonlyFields.includes('status');
const isPriorityReadonly = readonlyFields.includes('priority');
const isDueDateReadonly = readonlyFields.includes('dueDate');
// Déterminer le message selon la source
const getSyncMessage = (field: string) => {
if (source === 'jira') {
return field === 'title' || field === 'description' || field === 'dueDate'
? '(synchronisé par Jira)'
: '(sync)';
} else if (source === 'tfs') {
return field === 'title' || field === 'description' || field === 'dueDate'
? '(synchronisé par TFS)'
: '(sync)';
}
return '(sync)';
};
return (
<>
{/* Titre */}
<Input
label="Titre *"
value={title}
onChange={(e) => onTitleChange(e.target.value)}
placeholder="Titre de la tâche..."
error={errors.title}
disabled={loading}
/>
<div className="space-y-2">
<div className="flex items-center gap-2">
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Titre *
</label>
{isTitleReadonly && (
<span className="text-xs text-[var(--muted-foreground)] italic">
{getSyncMessage('title')}
</span>
)}
</div>
<Input
value={title}
onChange={(e) => onTitleChange(e.target.value)}
placeholder="Titre de la tâche..."
error={errors.title}
disabled={loading || isTitleReadonly}
/>
</div>
{/* Description */}
<div className="space-y-2">
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Description
</label>
<div className="flex items-center gap-2">
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Description
</label>
{isDescriptionReadonly && (
<span className="text-xs text-[var(--muted-foreground)] italic">
{getSyncMessage('description')}
</span>
)}
</div>
<textarea
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
placeholder="Description détaillée..."
rows={4}
disabled={loading}
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm placeholder-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm resize-none"
disabled={loading || isDescriptionReadonly}
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm placeholder-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm resize-none disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-[var(--card-column)] disabled:text-[var(--muted-foreground)] disabled:border-[var(--border)]/30"
/>
{errors.description && (
<p className="text-xs font-mono text-red-400 flex items-center gap-1">
@@ -71,14 +113,21 @@ export function TaskBasicFields({
{/* Priorité et Statut */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Priorité
</label>
<div className="flex items-center gap-2">
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Priorité
</label>
{isPriorityReadonly && (
<span className="text-xs text-[var(--muted-foreground)] italic">
{getSyncMessage('priority')}
</span>
)}
</div>
<select
value={priority}
onChange={(e) => onPriorityChange(e.target.value as TaskPriority)}
disabled={loading}
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
disabled={loading || isPriorityReadonly}
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-[var(--card-column)] disabled:text-[var(--muted-foreground)] disabled:border-[var(--border)]/30"
>
{getAllPriorities().map((priorityConfig) => (
<option key={priorityConfig.key} value={priorityConfig.key}>
@@ -89,14 +138,21 @@ export function TaskBasicFields({
</div>
<div className="space-y-2">
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Statut
</label>
<div className="flex items-center gap-2">
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Statut
</label>
{isStatusReadonly && (
<span className="text-xs text-[var(--muted-foreground)] italic">
{getSyncMessage('status')}
</span>
)}
</div>
<select
value={status}
onChange={(e) => onStatusChange(e.target.value as TaskStatus)}
disabled={loading}
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
disabled={loading || isStatusReadonly}
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-[var(--card-column)] disabled:text-[var(--muted-foreground)] disabled:border-[var(--border)]/30"
>
{getAllStatuses().map((statusConfig) => (
<option key={statusConfig.key} value={statusConfig.key}>
@@ -109,13 +165,20 @@ export function TaskBasicFields({
{/* Date d'échéance */}
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-2">
Date d&apos;échéance
</label>
<div className="flex items-center gap-2 mb-2">
<label className="block text-sm font-medium text-[var(--foreground)]">
Date d&apos;échéance
</label>
{isDueDateReadonly && (
<span className="text-xs text-[var(--muted-foreground)] italic">
{getSyncMessage('dueDate')}
</span>
)}
</div>
<DateTimeInput
value={ensureDate(dueDate) ?? undefined}
onChange={(newDate) => onDueDateChange(newDate)}
disabled={loading}
disabled={loading || isDueDateReadonly}
placeholder="Sélectionner une date d'échéance..."
/>
</div>

View File

@@ -66,6 +66,7 @@ export interface Task {
assignee?: string;
ownerId: string; // ID du propriétaire de la tâche
todosCount?: number; // Nombre de todos reliés à cette tâche
readonlyFields?: string[]; // Liste des champs en lecture seule (pour les tâches synchronisées)
}
// Interface pour les tags

View File

@@ -7,6 +7,7 @@ import { JiraTask } from '@/lib/types';
import { prisma } from '@/services/core/database';
import { parseDate, formatDateForDisplay } from '@/lib/date-utils';
import { tagsService } from '../../task-management/tags';
import { buildSyncUpdateData } from '../../task-management/readonly-fields';
export interface JiraConfig {
enabled: boolean;
@@ -527,50 +528,50 @@ export class JiraService {
} else {
// Toujours mettre à jour les données Jira (écrasement forcé)
// Utiliser la logique centralisée pour déterminer quels champs préserver
const finalData = buildSyncUpdateData('jira', existingTask, taskData);
// Détecter les changements et créer la liste des modifications
const changes: string[] = [];
// Préserver le titre et la priorité si modifiés localement
const finalTitle =
existingTask.title !== taskData.title
? existingTask.title
: taskData.title;
const finalPriority =
existingTask.priority !== taskData.priority
? existingTask.priority
: taskData.priority;
// Préserver le statut archived local - ne jamais désarchiver depuis Jira
const finalStatus =
existingTask.status === 'archived' ? 'archived' : taskData.status;
if (existingTask.title !== taskData.title) {
// Détecter les changements en comparant les valeurs finales avec les existantes
if (existingTask.title !== finalData.title) {
changes.push(`Titre: ${existingTask.title}${finalData.title}`);
} else if (existingTask.title !== taskData.title) {
// Valeur préservée (finalData === existingTask mais différent de taskData)
changes.push(`Titre: préservé localement ("${existingTask.title}")`);
}
if (existingTask.description !== taskData.description) {
if (existingTask.description !== finalData.description) {
changes.push(`Description modifiée`);
}
if (
existingTask.status === 'archived' &&
taskData.status !== 'archived'
) {
changes.push(`Statut: préservé localement (archived)`);
} else if (existingTask.status !== finalStatus) {
changes.push(`Statut: ${existingTask.status}${finalStatus}`);
if (existingTask.status !== finalData.status) {
changes.push(`Statut: ${existingTask.status}${finalData.status}`);
} else if (existingTask.status !== taskData.status) {
// Valeur préservée (finalData === existingTask mais différent de taskData)
changes.push(`Statut: préservé localement (${existingTask.status})`);
}
if (existingTask.priority !== taskData.priority) {
if (existingTask.priority !== finalData.priority) {
changes.push(
`Priorité: ${existingTask.priority}${finalData.priority}`
);
} else if (existingTask.priority !== taskData.priority) {
// Valeur préservée (finalData === existingTask mais différent de taskData)
changes.push(
`Priorité: préservée localement (${existingTask.priority})`
);
}
if (
(existingTask.dueDate?.getTime() || null) !==
(taskData.dueDate?.getTime() || null)
(finalData.dueDate?.getTime() || null)
) {
const oldDate = existingTask.dueDate
? formatDateForDisplay(existingTask.dueDate)
: 'Aucune';
const newDate = taskData.dueDate
? formatDateForDisplay(taskData.dueDate)
const newDate = finalData.dueDate
? formatDateForDisplay(finalData.dueDate)
: 'Aucune';
changes.push(`Échéance: ${oldDate}${newDate}`);
}
@@ -604,7 +605,7 @@ export class JiraService {
};
}
// Préparer les données de mise à jour
// Préparer les données de mise à jour en utilisant les valeurs finales déterminées par la logique centralisée
const updateData: {
title: string;
description: string | null;
@@ -618,11 +619,7 @@ export class JiraService {
updatedAt: Date;
completedAt?: Date;
} = {
title: finalTitle,
description: taskData.description,
status: finalStatus,
priority: finalPriority,
dueDate: taskData.dueDate,
...finalData,
jiraProject: taskData.jiraProject,
jiraKey: taskData.jiraKey,
jiraType: taskData.jiraType,
@@ -631,7 +628,7 @@ export class JiraService {
};
// Si la tâche passe à "done" et n'a pas encore de completedAt, le définir
if (finalStatus === 'done' && !existingTask.completedAt) {
if (finalData.status === 'done' && !existingTask.completedAt) {
updateData.completedAt = new Date();
console.log(` ✅ Tâche marquée comme terminée (completedAt défini)`);
}
@@ -657,6 +654,49 @@ export class JiraService {
}
}
/**
* Détermine quels champs sont en lecture seule pour une tâche Jira
* Ces champs sont ceux qui sont écrasés par la synchronisation Jira
* @deprecated Utiliser getReadonlyFieldsForTask() depuis readonly-fields.ts à la place
*/
async getReadonlyFields(taskId: string): Promise<string[]> {
try {
const task = await prisma.task.findUnique({
where: { id: taskId },
select: {
source: true,
status: true,
},
});
// Si ce n'est pas une tâche Jira, aucun champ n'est en lecture seule
if (!task || task.source !== 'jira') {
return [];
}
// Utiliser la logique centralisée
const { getReadonlyFieldsForTask } = await import(
'../../task-management/readonly-fields'
);
return getReadonlyFieldsForTask(
'jira',
task.status as
| 'backlog'
| 'todo'
| 'in_progress'
| 'done'
| 'cancelled'
| 'archived'
);
} catch (error) {
console.error(
'Erreur lors de la détermination des champs readonly:',
error
);
return [];
}
}
/**
* Nettoie les tâches Jira qui ne sont plus assignées à l'utilisateur
* Ne supprime PAS les tâches déjà terminées (done/archived) pour garder l'historique

View File

@@ -9,6 +9,7 @@ import { prisma } from '@/services/core/database';
import { parseDate, formatDateForDisplay } from '@/lib/date-utils';
import { userPreferencesService } from '@/services/core/user-preferences';
import { tagsService } from '../../task-management/tags';
import { buildSyncUpdateData } from '../../task-management/readonly-fields';
export interface TfsConfig {
enabled: boolean;
@@ -674,18 +675,38 @@ export class TfsService {
prTitle: pr.title,
};
} else {
// Utiliser la logique centralisée pour déterminer quels champs préserver
const finalData = buildSyncUpdateData('tfs', existingTask, taskData);
// Détecter les changements
const changes: string[] = [];
if (existingTask.title !== taskData.title) {
changes.push(`Titre: ${existingTask.title}${taskData.title}`);
if (existingTask.title !== finalData.title) {
changes.push(`Titre: ${existingTask.title}${finalData.title}`);
}
if (existingTask.status !== taskData.status) {
changes.push(`Statut: ${existingTask.status}${taskData.status}`);
if (existingTask.status !== finalData.status) {
changes.push(`Statut: ${existingTask.status}${finalData.status}`);
}
if (existingTask.description !== taskData.description) {
if (existingTask.description !== finalData.description) {
changes.push('Description modifiée');
}
if (existingTask.priority !== finalData.priority) {
changes.push(
`Priorité: ${existingTask.priority}${finalData.priority}`
);
}
if (
(existingTask.dueDate?.getTime() || null) !==
(finalData.dueDate?.getTime() || null)
) {
const oldDate = existingTask.dueDate
? formatDateForDisplay(existingTask.dueDate)
: 'Aucune';
const newDate = finalData.dueDate
? formatDateForDisplay(finalData.dueDate)
: 'Aucune';
changes.push(`Échéance: ${oldDate}${newDate}`);
}
if (existingTask.assignee !== taskData.assignee) {
changes.push(
`Assigné: ${existingTask.assignee}${taskData.assignee}`
@@ -704,11 +725,17 @@ export class TfsService {
};
}
// Mettre à jour la tâche
// Mettre à jour la tâche avec les données finales déterminées par la logique centralisée
await prisma.task.update({
where: { id: existingTask.id },
data: {
...taskData,
...finalData,
tfsProject: taskData.tfsProject,
tfsPullRequestId: taskData.tfsPullRequestId,
tfsRepository: taskData.tfsRepository,
tfsSourceBranch: taskData.tfsSourceBranch,
tfsTargetBranch: taskData.tfsTargetBranch,
assignee: taskData.assignee,
updatedAt: new Date(),
},
});
@@ -722,6 +749,49 @@ export class TfsService {
}
}
/**
* Détermine quels champs sont en lecture seule pour une tâche TFS
* Ces champs sont ceux qui sont écrasés par la synchronisation TFS
* @deprecated Utiliser getReadonlyFieldsForTask() depuis readonly-fields.ts à la place
*/
async getReadonlyFields(taskId: string): Promise<string[]> {
try {
const task = await prisma.task.findUnique({
where: { id: taskId },
select: {
source: true,
status: true,
},
});
// Si ce n'est pas une tâche TFS, aucun champ n'est en lecture seule
if (!task || task.source !== 'tfs') {
return [];
}
// Utiliser la logique centralisée
const { getReadonlyFieldsForTask } = await import(
'../../task-management/readonly-fields'
);
return getReadonlyFieldsForTask(
'tfs',
task.status as
| 'backlog'
| 'todo'
| 'in_progress'
| 'done'
| 'cancelled'
| 'archived'
);
} catch (error) {
console.error(
'Erreur lors de la détermination des champs readonly:',
error
);
return [];
}
}
/**
* S'assure que le tag TFS existe
*/

View File

@@ -0,0 +1,255 @@
/**
* Tests unitaires pour la logique de détermination des champs readonly
*/
import {
getReadonlyFieldsForTask,
shouldPreserveFieldDuringSync,
buildSyncUpdateData,
} from '../readonly-fields';
describe('getReadonlyFieldsForTask', () => {
describe('Jira', () => {
it('devrait retourner description et dueDate comme readonly', () => {
const result = getReadonlyFieldsForTask('jira', 'todo');
expect(result).toContain('description');
expect(result).toContain('dueDate');
expect(result).toContain('status');
expect(result).not.toContain('title');
expect(result).not.toContain('priority');
});
it('ne devrait pas retourner status comme readonly si archived', () => {
const result = getReadonlyFieldsForTask('jira', 'archived');
expect(result).toContain('description');
expect(result).toContain('dueDate');
expect(result).not.toContain('status');
});
it('devrait permettre title et priority comme modifiables', () => {
const result = getReadonlyFieldsForTask('jira', 'todo');
expect(result).not.toContain('title');
expect(result).not.toContain('priority');
});
});
describe('TFS', () => {
it('devrait retourner tous les champs comme readonly', () => {
const result = getReadonlyFieldsForTask('tfs', 'todo');
expect(result).toContain('title');
expect(result).toContain('description');
expect(result).toContain('status');
expect(result).toContain('priority');
expect(result).toContain('dueDate');
});
it('devrait retourner tous les champs même si archived', () => {
const result = getReadonlyFieldsForTask('tfs', 'archived');
expect(result).toContain('title');
expect(result).toContain('status');
});
});
describe('Manual', () => {
it('ne devrait retourner aucun champ readonly', () => {
const result = getReadonlyFieldsForTask('manual', 'todo');
expect(result).toHaveLength(0);
});
});
});
describe('shouldPreserveFieldDuringSync', () => {
describe('Jira', () => {
it('devrait préserver title si modifié localement', () => {
const result = shouldPreserveFieldDuringSync(
'jira',
'title',
'Titre local modifié',
'Titre Jira original'
);
expect(result).toBe(true);
});
it('ne devrait pas préserver title si identique', () => {
const result = shouldPreserveFieldDuringSync(
'jira',
'title',
'Même titre',
'Même titre'
);
expect(result).toBe(false);
});
it('devrait préserver priority si modifié localement', () => {
const result = shouldPreserveFieldDuringSync(
'jira',
'priority',
'high',
'medium'
);
expect(result).toBe(true);
});
it('devrait préserver status si archived', () => {
const result = shouldPreserveFieldDuringSync(
'jira',
'status',
'archived',
'done',
'archived'
);
expect(result).toBe(true);
});
it('ne devrait pas préserver status si pas archived', () => {
const result = shouldPreserveFieldDuringSync(
'jira',
'status',
'todo',
'in_progress',
'todo'
);
expect(result).toBe(false);
});
it('ne devrait jamais préserver description', () => {
const result = shouldPreserveFieldDuringSync(
'jira',
'description',
'Description locale',
'Description Jira'
);
expect(result).toBe(false);
});
it('ne devrait jamais préserver dueDate', () => {
const result = shouldPreserveFieldDuringSync(
'jira',
'dueDate',
new Date('2024-01-01'),
new Date('2024-01-02')
);
expect(result).toBe(false);
});
});
describe('TFS', () => {
it('ne devrait jamais préserver aucun champ', () => {
expect(
shouldPreserveFieldDuringSync('tfs', 'title', 'Local', 'TFS')
).toBe(false);
expect(
shouldPreserveFieldDuringSync('tfs', 'description', 'Local', 'TFS')
).toBe(false);
expect(
shouldPreserveFieldDuringSync('tfs', 'status', 'todo', 'done')
).toBe(false);
expect(
shouldPreserveFieldDuringSync('tfs', 'priority', 'high', 'medium')
).toBe(false);
expect(
shouldPreserveFieldDuringSync(
'tfs',
'dueDate',
new Date('2024-01-01'),
new Date('2024-01-02')
)
).toBe(false);
});
});
});
describe('buildSyncUpdateData', () => {
describe('Jira', () => {
const existingTask = {
title: 'Tâche locale',
description: 'Description locale',
status: 'todo',
priority: 'high',
dueDate: new Date('2024-01-01'),
};
const syncData = {
title: 'Tâche Jira',
description: 'Description Jira',
status: 'in_progress',
priority: 'medium',
dueDate: new Date('2024-01-02'),
};
it('devrait préserver title si modifié localement', () => {
const result = buildSyncUpdateData('jira', existingTask, syncData);
expect(result.title).toBe('Tâche locale');
});
it('devrait préserver priority si modifié localement', () => {
const result = buildSyncUpdateData('jira', existingTask, syncData);
expect(result.priority).toBe('high');
});
it('devrait écraser description', () => {
const result = buildSyncUpdateData('jira', existingTask, syncData);
expect(result.description).toBe('Description Jira');
});
it('devrait écraser dueDate', () => {
const result = buildSyncUpdateData('jira', existingTask, syncData);
expect(result.dueDate).toEqual(new Date('2024-01-02'));
});
it('devrait écraser status si pas archived', () => {
const result = buildSyncUpdateData('jira', existingTask, syncData);
expect(result.status).toBe('in_progress');
});
it('devrait préserver status si archived', () => {
const archivedTask = { ...existingTask, status: 'archived' };
const result = buildSyncUpdateData('jira', archivedTask, syncData);
expect(result.status).toBe('archived');
});
it('devrait utiliser syncData si title identique', () => {
const sameTitleTask = { ...existingTask, title: 'Tâche Jira' };
const result = buildSyncUpdateData('jira', sameTitleTask, syncData);
expect(result.title).toBe('Tâche Jira');
});
});
describe('TFS', () => {
const existingTask = {
title: 'Tâche locale',
description: 'Description locale',
status: 'todo',
priority: 'high',
dueDate: new Date('2024-01-01'),
};
const syncData = {
title: 'PR: Tâche TFS',
description: 'Description TFS',
status: 'done',
priority: 'medium',
dueDate: null,
};
it('devrait écraser tous les champs', () => {
const result = buildSyncUpdateData('tfs', existingTask, syncData);
expect(result.title).toBe('PR: Tâche TFS');
expect(result.description).toBe('Description TFS');
expect(result.status).toBe('done');
expect(result.priority).toBe('medium');
expect(result.dueDate).toBeNull();
});
it('devrait écraser même si valeurs différentes localement', () => {
const modifiedTask = {
...existingTask,
title: 'Titre modifié localement',
priority: 'low',
};
const result = buildSyncUpdateData('tfs', modifiedTask, syncData);
expect(result.title).toBe('PR: Tâche TFS');
expect(result.priority).toBe('medium');
});
});
});

View File

@@ -0,0 +1,163 @@
/**
* Détermine quels champs sont en lecture seule pour une tâche synchronisée
* Cette logique centralisée évite la duplication et facilite la maintenance
*/
import { TaskSource, TaskStatus } from '@/lib/types';
/**
* Détermine les champs en lecture seule selon la source de synchronisation
* @param source - Source de la tâche (jira, tfs, manual)
* @param status - Statut actuel de la tâche (pour les règles spécifiques comme archived)
* @returns Liste des noms de champs en lecture seule
*/
export function getReadonlyFieldsForTask(
source: TaskSource,
status?: TaskStatus
): string[] {
const readonlyFields: string[] = [];
if (source === 'jira') {
// description: toujours écrasé par Jira → non modifiable
readonlyFields.push('description');
// dueDate: toujours écrasé par Jira → non modifiable
readonlyFields.push('dueDate');
// status: écrasé par Jira SAUF si archived (car archived est préservé localement)
if (status !== 'archived') {
readonlyFields.push('status');
}
// title et priority: peuvent être préservés si modifiés localement → modifiables
} else if (source === 'tfs') {
// Pour TFS, tous les champs synchronisés sont écrasés → non modifiables
readonlyFields.push('title');
readonlyFields.push('description');
readonlyFields.push('status');
readonlyFields.push('priority');
readonlyFields.push('dueDate');
}
// Pour 'manual', aucun champ n'est readonly
return readonlyFields;
}
/**
* Détermine si un champ doit être préservé lors de la synchronisation
* Cette fonction centralise la logique métier de préservation des champs
* @param source - Source de la tâche (jira, tfs, manual)
* @param field - Nom du champ à vérifier
* @param existingValue - Valeur actuelle dans la base de données
* @param syncValue - Valeur venant de la source externe (Jira/TFS)
* @param currentStatus - Statut actuel de la tâche (pour les règles spécifiques)
* @returns true si le champ doit être préservé (pas écrasé), false sinon
*/
export function shouldPreserveFieldDuringSync(
source: TaskSource,
field: 'title' | 'description' | 'status' | 'priority' | 'dueDate',
existingValue: unknown,
syncValue: unknown,
currentStatus?: TaskStatus
): boolean {
if (source === 'jira') {
// Pour Jira, certains champs peuvent être préservés s'ils ont été modifiés localement
if (field === 'title' || field === 'priority') {
// Préserver si la valeur locale est différente de celle de Jira (modifié localement)
return existingValue !== syncValue;
}
if (field === 'status') {
// Préserver le statut archived local - ne jamais désarchiver depuis Jira
return currentStatus === 'archived';
}
// description et dueDate: toujours écrasés
return false;
} else if (source === 'tfs') {
// Pour TFS, tous les champs sont écrasés (aucun n'est préservé)
return false;
}
// Pour 'manual', pas de synchronisation
return false;
}
/**
* Interface pour les données de synchronisation d'une tâche
*/
export interface SyncTaskData {
title: string;
description: string | null;
status: string;
priority: string;
dueDate: Date | null;
}
/**
* Interface pour les données existantes d'une tâche
*/
export interface ExistingTaskData {
title: string;
description: string | null;
status: string;
priority: string;
dueDate: Date | null;
}
/**
* Construit les données de mise à jour en appliquant la logique de préservation centralisée
* @param source - Source de la tâche (jira, tfs, manual)
* @param existingTask - Données existantes de la tâche
* @param syncData - Données venant de la source externe
* @returns Données finales à utiliser pour la mise à jour
*/
export function buildSyncUpdateData(
source: TaskSource,
existingTask: ExistingTaskData,
syncData: SyncTaskData
): SyncTaskData {
const currentStatus = existingTask.status as TaskStatus;
return {
title: shouldPreserveFieldDuringSync(
source,
'title',
existingTask.title,
syncData.title
)
? existingTask.title
: syncData.title,
description: shouldPreserveFieldDuringSync(
source,
'description',
existingTask.description,
syncData.description
)
? existingTask.description
: syncData.description,
status: shouldPreserveFieldDuringSync(
source,
'status',
existingTask.status,
syncData.status,
currentStatus
)
? existingTask.status
: syncData.status,
priority: shouldPreserveFieldDuringSync(
source,
'priority',
existingTask.priority,
syncData.priority
)
? existingTask.priority
: syncData.priority,
dueDate: shouldPreserveFieldDuringSync(
source,
'dueDate',
existingTask.dueDate,
syncData.dueDate
)
? existingTask.dueDate
: syncData.dueDate,
};
}

View File

@@ -12,6 +12,7 @@ import {
import { Prisma } from '@prisma/client';
import { getToday } from '@/lib/date-utils';
import { tagsService } from './tags';
import { getReadonlyFieldsForTask } from './readonly-fields';
/**
* Service pour la gestion des tâches (version standalone)
@@ -438,6 +439,12 @@ export class TasksService {
todosCount = prismaTask._count.dailyCheckboxes || 0;
}
// Déterminer les champs en lecture seule selon la source de synchronisation
const readonlyFields = getReadonlyFieldsForTask(
prismaTask.source as TaskSource,
prismaTask.status as TaskStatus
);
return {
id: prismaTask.id,
title: prismaTask.title,
@@ -475,6 +482,7 @@ export class TasksService {
assignee: prismaTask.assignee ?? undefined,
ownerId: prismaTask.ownerId,
todosCount: todosCount,
readonlyFields: readonlyFields.length > 0 ? readonlyFields : undefined,
};
}
}