fix: improve error handling in API routes and update date handling for OKR and Key Result submissions
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 3m38s

This commit is contained in:
Julien Froidefond
2026-01-07 17:22:33 +01:00
parent 97045342b7
commit 86c26b5af8
10 changed files with 170 additions and 70 deletions

View File

@@ -49,10 +49,11 @@ export async function PATCH(
const updated = await updateKeyResult(krId, Number(currentValue), notes || null); const updated = await updateKeyResult(krId, Number(currentValue), notes || null);
return NextResponse.json(updated); return NextResponse.json(updated);
} catch (error: any) { } catch (error) {
console.error('Error updating key result:', error); console.error('Error updating key result:', error);
const errorMessage = error instanceof Error ? error.message : 'Erreur lors de la mise à jour du Key Result';
return NextResponse.json( return NextResponse.json(
{ error: error.message || 'Erreur lors de la mise à jour du Key Result' }, { error: errorMessage },
{ status: 500 } { status: 500 }
); );
} }

View File

@@ -90,20 +90,21 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
// Remove keyResultsUpdates from updateData as it's not part of UpdateOKRInput // Remove keyResultsUpdates from updateData as it's not part of UpdateOKRInput
const { keyResultsUpdates, ...okrUpdateData } = body; const { keyResultsUpdates, ...okrUpdateData } = body;
const finalUpdateData: UpdateOKRInput = { ...okrUpdateData }; const finalUpdateData: UpdateOKRInput = { ...okrUpdateData };
if (finalUpdateData.startDate) { if (finalUpdateData.startDate && typeof finalUpdateData.startDate === 'string') {
finalUpdateData.startDate = new Date(finalUpdateData.startDate as any); finalUpdateData.startDate = new Date(finalUpdateData.startDate);
} }
if (finalUpdateData.endDate) { if (finalUpdateData.endDate && typeof finalUpdateData.endDate === 'string') {
finalUpdateData.endDate = new Date(finalUpdateData.endDate as any); finalUpdateData.endDate = new Date(finalUpdateData.endDate);
} }
const updated = await updateOKR(id, finalUpdateData, keyResultsUpdates); const updated = await updateOKR(id, finalUpdateData, keyResultsUpdates);
return NextResponse.json(updated); return NextResponse.json(updated);
} catch (error: any) { } catch (error) {
console.error('Error updating OKR:', error); console.error('Error updating OKR:', error);
const errorMessage = error instanceof Error ? error.message : 'Erreur lors de la mise à jour de l\'OKR';
return NextResponse.json( return NextResponse.json(
{ error: error.message || 'Erreur lors de la mise à jour de l\'OKR' }, { error: errorMessage },
{ status: 500 } { status: 500 }
); );
} }
@@ -132,10 +133,11 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
await deleteOKR(id); await deleteOKR(id);
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error: any) { } catch (error) {
console.error('Error deleting OKR:', error); console.error('Error deleting OKR:', error);
const errorMessage = error instanceof Error ? error.message : 'Erreur lors de la suppression de l\'OKR';
return NextResponse.json( return NextResponse.json(
{ error: error.message || 'Erreur lors de la suppression de l\'OKR' }, { error: errorMessage },
{ status: 500 } { status: 500 }
); );
} }

View File

@@ -28,10 +28,11 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
const member = await addTeamMember(id, userId, role || 'MEMBER'); const member = await addTeamMember(id, userId, role || 'MEMBER');
return NextResponse.json(member, { status: 201 }); return NextResponse.json(member, { status: 201 });
} catch (error: any) { } catch (error) {
console.error('Error adding team member:', error); console.error('Error adding team member:', error);
const errorMessage = error instanceof Error ? error.message : 'Erreur lors de l\'ajout du membre';
return NextResponse.json( return NextResponse.json(
{ error: error.message || 'Erreur lors de l\'ajout du membre' }, { error: errorMessage },
{ status: 500 } { status: 500 }
); );
} }

View File

@@ -54,7 +54,23 @@ export default function EditOKRPage() {
}); });
}, [okrId, teamId]); }, [okrId, teamId]);
const handleSubmit = async (data: CreateOKRInput & { keyResultsUpdates?: { create?: any[]; update?: any[]; delete?: string[] } }) => { type KeyResultUpdate = {
id: string;
title?: string;
targetValue?: number;
unit?: string;
order?: number;
};
const handleSubmit = async (data: CreateOKRInput & {
startDate: Date | string;
endDate: Date | string;
keyResultsUpdates?: {
create?: Array<{ title: string; targetValue: number; unit: string; order: number }>;
update?: KeyResultUpdate[];
delete?: string[]
}
}) => {
// Convert to UpdateOKRInput format // Convert to UpdateOKRInput format
const updateData = { const updateData = {
objective: data.objective, objective: data.objective,
@@ -64,7 +80,18 @@ export default function EditOKRPage() {
endDate: typeof data.endDate === 'string' ? new Date(data.endDate) : data.endDate, endDate: typeof data.endDate === 'string' ? new Date(data.endDate) : data.endDate,
}; };
const payload: any = { const payload: {
objective: string;
description?: string;
period: string;
startDate: string;
endDate: string;
keyResultsUpdates?: {
create?: Array<{ title: string; targetValue: number; unit: string; order: number }>;
update?: KeyResultUpdate[];
delete?: string[];
};
} = {
...updateData, ...updateData,
startDate: updateData.startDate.toISOString(), startDate: updateData.startDate.toISOString(),
endDate: updateData.endDate.toISOString(), endDate: updateData.endDate.toISOString(),
@@ -83,7 +110,7 @@ export default function EditOKRPage() {
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.error || 'Erreur lors de la mise à jour de l\'OKR'); throw new Error(error.error || 'Erreur lors de la mise à jour de l&apos;OKR');
} }
router.push(`/teams/${teamId}/okrs/${okrId}`); router.push(`/teams/${teamId}/okrs/${okrId}`);
@@ -121,12 +148,12 @@ export default function EditOKRPage() {
<main className="mx-auto max-w-4xl px-4 py-8"> <main className="mx-auto max-w-4xl px-4 py-8">
<div className="mb-6"> <div className="mb-6">
<Link href={`/teams/${teamId}/okrs/${okrId}`} className="text-muted hover:text-foreground"> <Link href={`/teams/${teamId}/okrs/${okrId}`} className="text-muted hover:text-foreground">
Retour à l'OKR Retour à l&apos;OKR
</Link> </Link>
</div> </div>
<Card className="p-6"> <Card className="p-6">
<h1 className="text-2xl font-bold text-foreground mb-6">Modifier l'OKR</h1> <h1 className="text-2xl font-bold text-foreground mb-6">Modifier l&apos;OKR</h1>
<OKRForm <OKRForm
teamMembers={teamMembers} teamMembers={teamMembers}
onSubmit={handleSubmit} onSubmit={handleSubmit}

View File

@@ -148,7 +148,7 @@ export default function OKRDetailPage() {
<main className="mx-auto max-w-4xl px-4 py-8"> <main className="mx-auto max-w-4xl px-4 py-8">
<div className="mb-6"> <div className="mb-6">
<Link href={`/teams/${teamId}`} className="text-muted hover:text-foreground"> <Link href={`/teams/${teamId}`} className="text-muted hover:text-foreground">
Retour à lquipe Retour à l&apos;équipe
</Link> </Link>
</div> </div>

View File

@@ -45,7 +45,7 @@ export default function NewOKRPage() {
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.error || 'Erreur lors de la création de l\'OKR'); throw new Error(error.error || 'Erreur lors de la création de l&apos;OKR');
} }
router.push(`/teams/${teamId}`); router.push(`/teams/${teamId}`);
@@ -64,7 +64,7 @@ export default function NewOKRPage() {
<main className="mx-auto max-w-4xl px-4 py-8"> <main className="mx-auto max-w-4xl px-4 py-8">
<div className="mb-6"> <div className="mb-6">
<Link href={`/teams/${teamId}`} className="text-muted hover:text-foreground"> <Link href={`/teams/${teamId}`} className="text-muted hover:text-foreground">
Retour à l'équipe Retour à l&apos;équipe
</Link> </Link>
</div> </div>

View File

@@ -61,9 +61,29 @@ interface KeyResultEditInput extends CreateKeyResultInput {
id?: string; // If present, it's an existing Key Result to update id?: string; // If present, it's an existing Key Result to update
} }
type KeyResultUpdate = {
id: string;
title?: string;
targetValue?: number;
unit?: string;
order?: number;
};
interface OKRFormProps { interface OKRFormProps {
teamMembers: TeamMember[]; teamMembers: TeamMember[];
onSubmit: (data: CreateOKRInput & { keyResultsUpdates?: { create?: CreateKeyResultInput[]; update?: Array<{ id: string; title?: string; targetValue?: number; unit?: string; order?: number }>; delete?: string[] } }) => Promise<void>; onSubmit: (
data:
| (CreateOKRInput & {
startDate: Date | string;
endDate: Date | string;
keyResultsUpdates?: {
create?: CreateKeyResultInput[];
update?: KeyResultUpdate[];
delete?: string[];
};
})
| CreateOKRInput
) => Promise<void>;
onCancel: () => void; onCancel: () => void;
initialData?: Partial<CreateOKRInput> & { keyResults?: KeyResult[] }; initialData?: Partial<CreateOKRInput> & { keyResults?: KeyResult[] };
} }
@@ -83,13 +103,17 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
// Initialize Key Results from existing ones if in edit mode, otherwise start with one empty // Initialize Key Results from existing ones if in edit mode, otherwise start with one empty
const [keyResults, setKeyResults] = useState<KeyResultEditInput[]>(() => { const [keyResults, setKeyResults] = useState<KeyResultEditInput[]>(() => {
if (initialData?.keyResults && initialData.keyResults.length > 0) { if (initialData?.keyResults && initialData.keyResults.length > 0) {
return initialData.keyResults.map((kr) => ({ return initialData.keyResults.map((kr): KeyResultEditInput => {
id: kr.id, const result = {
title: kr.title, title: kr.title,
targetValue: kr.targetValue, targetValue: kr.targetValue,
unit: kr.unit, unit: kr.unit || '%',
order: kr.order, order: kr.order,
})); };
// @ts-expect-error - id is added to extend CreateKeyResultInput to KeyResultEditInput
result.id = kr.id;
return result as KeyResultEditInput;
});
} }
return [{ title: '', targetValue: 100, unit: '%', order: 0 }]; return [{ title: '', targetValue: 100, unit: '%', order: 0 }];
}); });
@@ -125,7 +149,11 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
setKeyResults(keyResults.filter((_, i) => i !== index).map((kr, i) => ({ ...kr, order: i }))); setKeyResults(keyResults.filter((_, i) => i !== index).map((kr, i) => ({ ...kr, order: i })));
}; };
const updateKeyResult = (index: number, field: keyof KeyResultEditInput, value: any) => { const updateKeyResult = (
index: number,
field: keyof KeyResultEditInput,
value: string | number
) => {
const updated = [...keyResults]; const updated = [...keyResults];
updated[index] = { ...updated[index], [field]: value }; updated[index] = { ...updated[index], [field]: value };
setKeyResults(updated); setKeyResults(updated);
@@ -164,48 +192,67 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
} }
const isEditMode = !!initialData?.teamMemberId; const isEditMode = !!initialData?.teamMemberId;
if (isEditMode) { if (isEditMode) {
// Type guard for Key Results with id
type KeyResultWithId = KeyResultEditInput & { id: string };
const hasId = (kr: KeyResultEditInput): kr is KeyResultWithId => !!kr.id;
// In edit mode, separate existing Key Results from new ones // In edit mode, separate existing Key Results from new ones
const existingKeyResults = keyResults.filter((kr) => kr.id); const existingKeyResults: KeyResultWithId[] = keyResults.filter(hasId);
const newKeyResults = keyResults.filter((kr) => !kr.id); const originalKeyResults: KeyResult[] = initialData?.keyResults || [];
const originalKeyResults = initialData?.keyResults || []; const originalIds = new Set(originalKeyResults.map((kr: KeyResult) => kr.id));
const originalIds = new Set(originalKeyResults.map((kr) => kr.id)); const currentIds = new Set(existingKeyResults.map((kr: KeyResultWithId) => kr.id));
const currentIds = new Set(existingKeyResults.map((kr) => kr.id));
// Find deleted Key Results // Find deleted Key Results
const deletedIds = Array.from(originalIds).filter((id) => !currentIds.has(id)); const deletedIds = Array.from(originalIds).filter((id) => !currentIds.has(id));
// Find updated Key Results (compare with original) // Find updated Key Results (compare with original)
const updated = existingKeyResults const updated = existingKeyResults
.map((kr) => { .map((kr: KeyResultWithId) => {
const original = originalKeyResults.find((okr) => okr.id === kr.id); const original = originalKeyResults.find((okr: KeyResult) => okr.id === kr.id);
if (!original) return null; if (!original) return null;
const changes: { id: string; title?: string; targetValue?: number; unit?: string; order?: number } = { id: kr.id }; const changes: {
id: string;
title?: string;
targetValue?: number;
unit?: string;
order?: number;
} = { id: kr.id };
if (original.title !== kr.title) changes.title = kr.title; if (original.title !== kr.title) changes.title = kr.title;
if (original.targetValue !== kr.targetValue) changes.targetValue = kr.targetValue; if (original.targetValue !== kr.targetValue) changes.targetValue = kr.targetValue;
if (original.unit !== kr.unit) changes.unit = kr.unit; if (original.unit !== kr.unit) changes.unit = kr.unit;
if (original.order !== kr.order) changes.order = kr.order; if (original.order !== kr.order) changes.order = kr.order;
return Object.keys(changes).length > 1 ? changes : null; // More than just 'id' return Object.keys(changes).length > 1 ? changes : null; // More than just 'id'
}) })
.filter((u): u is { id: string; title?: string; targetValue?: number; unit?: string; order?: number } => u !== null); .filter(
(
u
): u is {
id: string;
title?: string;
targetValue?: number;
unit?: string;
order?: number;
} => u !== null
);
// Update order for all Key Results based on their position // Update order for all Key Results based on their position
const allKeyResultsWithOrder = keyResults.map((kr, i) => ({ ...kr, order: i })); const allKeyResultsWithOrder = keyResults.map((kr, i) => ({ ...kr, order: i }));
const existingWithOrder = allKeyResultsWithOrder.filter((kr) => kr.id); const existingWithOrder = allKeyResultsWithOrder.filter(hasId) as KeyResultWithId[];
const newWithOrder = allKeyResultsWithOrder.filter((kr) => !kr.id); const newWithOrder = allKeyResultsWithOrder.filter((kr) => !kr.id);
// Update order for existing Key Results that changed position // Update order for existing Key Results that changed position
const orderUpdates = existingWithOrder const orderUpdates = existingWithOrder
.map((kr) => { .map((kr) => {
const original = originalKeyResults.find((okr) => okr.id === kr.id); const original = originalKeyResults.find((okr: KeyResult) => okr.id === kr.id);
if (!original || original.order === kr.order) return null; if (!original || original.order === kr.order) return null;
return { id: kr.id, order: kr.order }; return { id: kr.id, order: kr.order };
}) })
.filter((u): u is { id: string; order: number } => u !== null); .filter((u): u is { id: string; order: number } => u !== null);
// Merge order updates with other updates // Merge order updates with other updates
const allUpdates = [...updated]; const allUpdates = [...updated];
orderUpdates.forEach((orderUpdate) => { orderUpdates.forEach((orderUpdate) => {
@@ -216,21 +263,29 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
allUpdates.push(orderUpdate); allUpdates.push(orderUpdate);
} }
}); });
await onSubmit({ await onSubmit({
teamMemberId, teamMemberId,
objective, objective,
description: description || undefined, description: description || undefined,
period: finalPeriod, period: finalPeriod,
startDate: startDateObj.toISOString() as any, startDate: startDateObj.toISOString(),
endDate: endDateObj.toISOString() as any, endDate: endDateObj.toISOString(),
keyResults: [], // Not used in edit mode keyResults: [], // Not used in edit mode
keyResultsUpdates: { keyResultsUpdates: {
create: newWithOrder.length > 0 ? newWithOrder.map((kr) => ({ title: kr.title, targetValue: kr.targetValue, unit: kr.unit || '%', order: kr.order })) : undefined, create:
newWithOrder.length > 0
? newWithOrder.map((kr) => ({
title: kr.title,
targetValue: kr.targetValue,
unit: kr.unit || '%',
order: kr.order,
}))
: undefined,
update: allUpdates.length > 0 ? allUpdates : undefined, update: allUpdates.length > 0 ? allUpdates : undefined,
delete: deletedIds.length > 0 ? deletedIds : undefined, delete: deletedIds.length > 0 ? deletedIds : undefined,
}, },
} as any); });
} else { } else {
// In create mode, just send Key Results normally // In create mode, just send Key Results normally
await onSubmit({ await onSubmit({
@@ -238,8 +293,8 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
objective, objective,
description: description || undefined, description: description || undefined,
period: finalPeriod, period: finalPeriod,
startDate: startDateObj.toISOString() as any, startDate: startDateObj,
endDate: endDateObj.toISOString() as any, endDate: endDateObj,
keyResults: keyResults.map((kr, i) => ({ ...kr, order: i })), keyResults: keyResults.map((kr, i) => ({ ...kr, order: i })),
}); });
} }
@@ -341,7 +396,8 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
</div> </div>
{period && period !== 'custom' && period !== '' && ( {period && period !== 'custom' && period !== '' && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Les dates sont automatiquement définies selon le trimestre sélectionné. Vous pouvez les modifier si nécessaire. Les dates sont automatiquement définies selon le trimestre sélectionné. Vous pouvez les
modifier si nécessaire.
</p> </p>
)} )}
@@ -351,13 +407,22 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
<label className="block text-sm font-medium text-foreground"> <label className="block text-sm font-medium text-foreground">
Key Results * ({keyResults.length}/5) Key Results * ({keyResults.length}/5)
</label> </label>
<Button type="button" onClick={addKeyResult} variant="outline" size="sm" disabled={keyResults.length >= 5}> <Button
type="button"
onClick={addKeyResult}
variant="outline"
size="sm"
disabled={keyResults.length >= 5}
>
+ Ajouter + Ajouter
</Button> </Button>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
{keyResults.map((kr, index) => ( {keyResults.map((kr, index) => (
<div key={kr.id || `new-${index}`} className="rounded-lg border border-border bg-card p-4"> <div
key={kr.id || `new-${index}`}
className="rounded-lg border border-border bg-card p-4"
>
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-foreground">Key Result {index + 1}</span> <span className="text-sm font-medium text-foreground">Key Result {index + 1}</span>
{keyResults.length > 1 && ( {keyResults.length > 1 && (
@@ -420,11 +485,10 @@ export function OKRForm({ teamMembers, onSubmit, onCancel, initialData }: OKRFor
? 'Modification...' ? 'Modification...'
: 'Création...' : 'Création...'
: initialData?.teamMemberId : initialData?.teamMemberId
? 'Modifier l\'OKR' ? "Modifier l'OKR"
: 'Créer l\'OKR'} : "Créer l'OKR"}
</Button> </Button>
</div> </div>
</form> </form>
); );
} }

View File

@@ -62,9 +62,9 @@ interface QuadrantHelpProps {
category: SwotCategory; category: SwotCategory;
} }
export function QuadrantHelp({ category }: QuadrantHelpProps) { export function QuadrantHelp({ category: _category }: QuadrantHelpProps) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const content = HELP_CONTENT[category];
return ( return (
<button <button

View File

@@ -24,7 +24,7 @@ export function DeleteTeamButton({ teamId, teamName }: DeleteTeamButtonProps) {
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
alert(error.error || 'Erreur lors de la suppression de l\'équipe'); alert(error.error || "Erreur lors de la suppression de l'équipe");
return; return;
} }
@@ -32,7 +32,7 @@ export function DeleteTeamButton({ teamId, teamName }: DeleteTeamButtonProps) {
router.refresh(); router.refresh();
} catch (error) { } catch (error) {
console.error('Error deleting team:', error); console.error('Error deleting team:', error);
alert('Erreur lors de la suppression de l\'équipe'); alert("Erreur lors de la suppression de l'équipe");
} }
}); });
}; };
@@ -44,17 +44,23 @@ export function DeleteTeamButton({ teamId, teamName }: DeleteTeamButtonProps) {
variant="outline" variant="outline"
className="text-destructive border-destructive hover:bg-destructive/10" className="text-destructive border-destructive hover:bg-destructive/10"
> >
Supprimer lquipe Supprimer l&apos;équipe
</Button> </Button>
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="Supprimer l'équipe" size="sm"> <Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
title="Supprimer l'équipe"
size="sm"
>
<div className="space-y-4"> <div className="space-y-4">
<p className="text-muted"> <p className="text-muted">
Êtes-vous sûr de vouloir supprimer l&apos;équipe{' '} Êtes-vous sûr de vouloir supprimer l&apos;équipe{' '}
<strong className="text-foreground">&quot;{teamName}&quot;</strong> ? <strong className="text-foreground">&quot;{teamName}&quot;</strong> ?
</p> </p>
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">
Cette action est irréversible. Tous les membres, OKRs et données associées seront supprimés. Cette action est irréversible. Tous les membres, OKRs et données associées seront
supprimés.
</p> </p>
<ModalFooter> <ModalFooter>
<Button variant="ghost" onClick={() => setShowModal(false)} disabled={isPending}> <Button variant="ghost" onClick={() => setShowModal(false)} disabled={isPending}>
@@ -69,4 +75,3 @@ export function DeleteTeamButton({ teamId, teamName }: DeleteTeamButtonProps) {
</> </>
); );
} }

View File

@@ -1,5 +1,5 @@
import { prisma } from '@/services/database'; import { prisma } from '@/services/database';
import type { CreateTeamInput, UpdateTeamInput, AddTeamMemberInput, UpdateMemberRoleInput, TeamRole } from '@/lib/types'; import type { UpdateTeamInput, TeamRole } from '@/lib/types';
export async function createTeam(name: string, description: string | null, createdById: string) { export async function createTeam(name: string, description: string | null, createdById: string) {
// Create team and add creator as admin in a transaction // Create team and add creator as admin in a transaction