feat(Tags): implement user-specific tag management and enhance related services

- Added ownerId field to Tag model to associate tags with users.
- Updated tagsService methods to enforce user ownership in tag operations.
- Enhanced API routes to include user authentication and ownership checks for tag retrieval and management.
- Modified seeding script to assign tags to the first user found in the database.
- Updated various components and services to ensure user-specific tag handling throughout the application.
This commit is contained in:
Julien Froidefond
2025-10-11 15:03:59 +02:00
parent 583efaa8c5
commit 7952459b42
17 changed files with 329 additions and 177 deletions

View File

@@ -0,0 +1,59 @@
-- Migration pour ajouter ownerId aux tags
-- Les tags existants seront assignés au premier utilisateur
-- Cette version préserve les relations TaskTag existantes
-- Étape 1: Ajouter la colonne ownerId temporairement nullable
ALTER TABLE "tags" ADD COLUMN "ownerId" TEXT;
-- Étape 2: Assigner tous les tags existants au premier utilisateur
UPDATE "tags"
SET "ownerId" = (
SELECT "id" FROM "users"
ORDER BY "createdAt" ASC
LIMIT 1
)
WHERE "ownerId" IS NULL;
-- Étape 3: Sauvegarder les relations TaskTag existantes avec les noms des tags
CREATE TEMPORARY TABLE "temp_task_tag_names" AS
SELECT tt."taskId", t."name" as "tagName"
FROM "task_tags" tt
JOIN "tags" t ON tt."tagId" = t."id";
-- Étape 4: Supprimer les anciennes relations TaskTag
DELETE FROM "task_tags";
-- Étape 5: Créer la nouvelle table avec ownerId non-nullable
CREATE TABLE "new_tags" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"color" TEXT NOT NULL DEFAULT '#6b7280',
"isPinned" BOOLEAN NOT NULL DEFAULT false,
"ownerId" TEXT NOT NULL,
CONSTRAINT "new_tags_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- Étape 6: Copier les données des tags
INSERT INTO "new_tags" ("id", "name", "color", "isPinned", "ownerId")
SELECT "id", "name", "color", "isPinned", "ownerId" FROM "tags";
-- Étape 7: Supprimer l'ancienne table
DROP TABLE "tags";
-- Étape 8: Renommer la nouvelle table
ALTER TABLE "new_tags" RENAME TO "tags";
-- Étape 9: Créer l'index unique pour (name, ownerId)
CREATE UNIQUE INDEX "tags_name_ownerId_key" ON "tags"("name", "ownerId");
-- Étape 10: Restaurer les relations TaskTag en utilisant les noms des tags
INSERT INTO "task_tags" ("taskId", "tagId")
SELECT tt."taskId", t."id" as "tagId"
FROM "temp_task_tag_names" tt
JOIN "tags" t ON tt."tagName" = t."name"
WHERE EXISTS (
SELECT 1 FROM "tasks" WHERE "tasks"."id" = tt."taskId"
);
-- Étape 11: Nettoyer la table temporaire
DROP TABLE "temp_task_tag_names";

View File

@@ -24,6 +24,7 @@ model User {
notes Note[] notes Note[]
dailyCheckboxes DailyCheckbox[] dailyCheckboxes DailyCheckbox[]
tasks Task[] @relation("TaskOwner") tasks Task[] @relation("TaskOwner")
tags Tag[] @relation("TagOwner")
@@map("users") @@map("users")
} }
@@ -63,13 +64,16 @@ model Task {
model Tag { model Tag {
id String @id @default(cuid()) id String @id @default(cuid())
name String @unique name String
color String @default("#6b7280") color String @default("#6b7280")
isPinned Boolean @default(false) isPinned Boolean @default(false)
ownerId String // Chaque tag appartient à un utilisateur
owner User @relation("TagOwner", fields: [ownerId], references: [id], onDelete: Cascade)
taskTags TaskTag[] taskTags TaskTag[]
primaryTasks Task[] @relation("PrimaryTag") primaryTasks Task[] @relation("PrimaryTag")
noteTags NoteTag[] noteTags NoteTag[]
@@unique([name, ownerId]) // Un nom de tag unique par utilisateur
@@map("tags") @@map("tags")
} }

View File

@@ -1,7 +1,22 @@
import { PrismaClient } from '@prisma/client';
import { tagsService } from '../src/services/task-management/tags'; import { tagsService } from '../src/services/task-management/tags';
const prisma = new PrismaClient();
async function seedTags() { async function seedTags() {
console.log('🏷️ Création des tags de test...'); console.log('🌱 Début du seeding des tags...');
// Récupérer le premier utilisateur pour assigner les tags
const firstUser = await prisma.user.findFirst({
orderBy: { createdAt: 'asc' },
});
if (!firstUser) {
console.log("❌ Aucun utilisateur trouvé. Créez d'abord un utilisateur.");
return;
}
console.log(`👤 Assignation des tags à: ${firstUser.email}`);
const testTags = [ const testTags = [
{ name: 'frontend', color: '#3B82F6' }, { name: 'frontend', color: '#3B82F6' },
@@ -19,9 +34,15 @@ async function seedTags() {
for (const tagData of testTags) { for (const tagData of testTags) {
try { try {
const existing = await tagsService.getTagByName(tagData.name); const existing = await tagsService.getTagByName(
tagData.name,
firstUser.id
);
if (!existing) { if (!existing) {
const tag = await tagsService.createTag(tagData); const tag = await tagsService.createTag({
...tagData,
userId: firstUser.id,
});
console.log(`✅ Tag créé: ${tag.name} (${tag.color})`); console.log(`✅ Tag créé: ${tag.name} (${tag.color})`);
} else { } else {
console.log(`⚠️ Tag existe déjà: ${tagData.name}`); console.log(`⚠️ Tag existe déjà: ${tagData.name}`);

View File

@@ -3,6 +3,8 @@
import { tagsService } from '@/services/task-management/tags'; import { tagsService } from '@/services/task-management/tags';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { Tag } from '@/lib/types'; import { Tag } from '@/lib/types';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/lib/auth';
export type ActionResult<T = void> = { export type ActionResult<T = void> = {
success: boolean; success: boolean;
@@ -18,7 +20,16 @@ export async function createTag(
color: string color: string
): Promise<ActionResult<Tag>> { ): Promise<ActionResult<Tag>> {
try { try {
const tag = await tagsService.createTag({ name, color }); const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return { success: false, error: 'User not authenticated' };
}
const tag = await tagsService.createTag({
name,
color,
userId: session.user.id,
});
// Revalider les pages qui utilisent les tags // Revalider les pages qui utilisent les tags
revalidatePath('/'); revalidatePath('/');
@@ -43,7 +54,12 @@ export async function updateTag(
data: { name?: string; color?: string; isPinned?: boolean } data: { name?: string; color?: string; isPinned?: boolean }
): Promise<ActionResult<Tag>> { ): Promise<ActionResult<Tag>> {
try { try {
const tag = await tagsService.updateTag(tagId, data); const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return { success: false, error: 'User not authenticated' };
}
const tag = await tagsService.updateTag(tagId, session.user.id, data);
if (!tag) { if (!tag) {
return { success: false, error: 'Tag non trouvé' }; return { success: false, error: 'Tag non trouvé' };
@@ -69,7 +85,12 @@ export async function updateTag(
*/ */
export async function deleteTag(tagId: string): Promise<ActionResult> { export async function deleteTag(tagId: string): Promise<ActionResult> {
try { try {
await tagsService.deleteTag(tagId); const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return { success: false, error: 'User not authenticated' };
}
await tagsService.deleteTag(tagId, session.user.id);
// Revalider les pages qui utilisent les tags // Revalider les pages qui utilisent les tags
revalidatePath('/'); revalidatePath('/');

View File

@@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { tagsService } from '@/services/task-management/tags'; import { tagsService } from '@/services/task-management/tags';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/lib/auth';
/** /**
* GET /api/tags/[id] - Récupère un tag par son ID * GET /api/tags/[id] - Récupère un tag par son ID
@@ -9,8 +11,14 @@ export async function GET(
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
// Vérifier l'authentification
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
}
const { id } = await params; const { id } = await params;
const tag = await tagsService.getTagById(id); const tag = await tagsService.getTagById(id, session.user.id);
if (!tag) { if (!tag) {
return NextResponse.json({ error: 'Tag non trouvé' }, { status: 404 }); return NextResponse.json({ error: 'Tag non trouvé' }, { status: 404 });

View File

@@ -1,11 +1,19 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { tagsService } from '@/services/task-management/tags'; import { tagsService } from '@/services/task-management/tags';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/lib/auth';
/** /**
* GET /api/tags - Récupère tous les tags ou recherche par query * GET /api/tags - Récupère tous les tags ou recherche par query
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
// Vérifier l'authentification
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
}
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const query = searchParams.get('q'); const query = searchParams.get('q');
const popular = searchParams.get('popular'); const popular = searchParams.get('popular');
@@ -14,14 +22,14 @@ export async function GET(request: NextRequest) {
let tags; let tags;
if (popular === 'true') { if (popular === 'true') {
// Récupérer les tags les plus utilisés // Récupérer les tags les plus utilisés pour cet utilisateur
tags = await tagsService.getPopularTags(limit); tags = await tagsService.getPopularTags(session.user.id, limit);
} else if (query) { } else if (query) {
// Recherche par nom (pour autocomplete) // Recherche par nom (pour autocomplete) pour cet utilisateur
tags = await tagsService.searchTags(query, limit); tags = await tagsService.searchTags(query, session.user.id, limit);
} else { } else {
// Récupérer tous les tags // Récupérer tous les tags de cet utilisateur
tags = await tagsService.getTags(); tags = await tagsService.getTags(session.user.id);
} }
return NextResponse.json({ return NextResponse.json({

View File

@@ -29,7 +29,7 @@ export default async function KanbanPage() {
// SSR - Récupération des données côté serveur // SSR - Récupération des données côté serveur
const [initialTasks, initialTags] = await Promise.all([ const [initialTasks, initialTags] = await Promise.all([
tasksService.getTasks(userId), tasksService.getTasks(userId),
tagsService.getTags(), tagsService.getTags(userId),
]); ]);
return ( return (

View File

@@ -33,7 +33,7 @@ export default async function NotesPage() {
// SSR - Récupération des données côté serveur // SSR - Récupération des données côté serveur
const initialNotes = await notesService.getNotes(session.user.id); const initialNotes = await notesService.getNotes(session.user.id);
const initialTags = await tagsService.getTags(); const initialTags = await tagsService.getTags(session.user.id);
return ( return (
<NotesPageClient initialNotes={initialNotes} initialTags={initialTags} /> <NotesPageClient initialNotes={initialNotes} initialTags={initialTags} />

View File

@@ -39,7 +39,7 @@ export default async function HomePage() {
tagMetrics, tagMetrics,
] = await Promise.all([ ] = await Promise.all([
tasksService.getTasks(userId), tasksService.getTasks(userId),
tagsService.getTags(), tagsService.getTags(userId),
tasksService.getTaskStats(userId), tasksService.getTaskStats(userId),
AnalyticsService.getProductivityMetrics(userId), AnalyticsService.getProductivityMetrics(userId),
DeadlineAnalyticsService.getDeadlineMetrics(userId), DeadlineAnalyticsService.getDeadlineMetrics(userId),

View File

@@ -31,7 +31,7 @@ export default async function AdvancedSettingsPage() {
// Fetch all data server-side // Fetch all data server-side
const [taskStats, tags] = await Promise.all([ const [taskStats, tags] = await Promise.all([
tasksService.getTaskStats(userId), tasksService.getTaskStats(userId),
tagsService.getTags(), tagsService.getTags(userId),
]); ]);
// Compose backup data like the API does // Compose backup data like the API does

View File

@@ -1,12 +1,31 @@
import { tagsService } from '@/services/task-management/tags'; import { tagsService } from '@/services/task-management/tags';
import { GeneralSettingsPageClient } from '@/components/settings/GeneralSettingsPageClient'; import { GeneralSettingsPageClient } from '@/components/settings/GeneralSettingsPageClient';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/lib/auth';
// Force dynamic rendering for real-time data // Force dynamic rendering for real-time data
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export default async function GeneralSettingsPage() { export default async function GeneralSettingsPage() {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return (
<div className="min-h-screen bg-[var(--background)] flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-[var(--foreground)] mb-2">
Accès non autorisé
</h1>
<p className="text-[var(--muted-foreground)]">
Vous devez être connecté pour accéder aux paramètres.
</p>
</div>
</div>
);
}
// Fetch data server-side // Fetch data server-side
const tags = await tagsService.getTags(); const tags = await tagsService.getTags(session.user.id);
return <GeneralSettingsPageClient initialTags={tags} />; return <GeneralSettingsPageClient initialTags={tags} />;
} }

View File

@@ -32,7 +32,7 @@ export default async function WeeklyManagerPage() {
const [summary, initialTasks, initialTags] = await Promise.all([ const [summary, initialTasks, initialTags] = await Promise.all([
ManagerSummaryService.getManagerSummary(userId), ManagerSummaryService.getManagerSummary(userId),
tasksService.getTasks(userId), tasksService.getTasks(userId),
tagsService.getTags(), tagsService.getTags(userId),
]); ]);
return ( return (

View File

@@ -6,6 +6,7 @@
import { JiraTask } from '@/lib/types'; import { JiraTask } from '@/lib/types';
import { prisma } from '@/services/core/database'; import { prisma } from '@/services/core/database';
import { parseDate, formatDateForDisplay } from '@/lib/date-utils'; import { parseDate, formatDateForDisplay } from '@/lib/date-utils';
import { tagsService } from '../../task-management/tags';
export interface JiraConfig { export interface JiraConfig {
enabled: boolean; enabled: boolean;
@@ -326,26 +327,13 @@ export class JiraService {
/** /**
* S'assure que le tag "🔗 From Jira" existe dans la base * S'assure que le tag "🔗 From Jira" existe dans la base
*/ */
private async ensureJiraTagExists(): Promise<void> { private async ensureJiraTagExists(userId: string): Promise<void> {
try { try {
const tagName = '🔗 From Jira'; const tagName = '🔗 From Jira';
// Vérifier si le tag existe déjà // Utiliser le service tags pour créer ou récupérer le tag
const existingTag = await prisma.tag.findUnique({ await tagsService.ensureTagsExist([tagName], userId);
where: { name: tagName }, console.log(`✅ Tag "${tagName}" créé/récupéré automatiquement`);
});
if (!existingTag) {
// Créer le tag s'il n'existe pas
await prisma.tag.create({
data: {
name: tagName,
color: '#0082C9', // Bleu Jira
isPinned: false,
},
});
console.log(`✅ Tag "${tagName}" créé automatiquement`);
}
} catch (error) { } catch (error) {
console.error('Erreur lors de la création du tag Jira:', error); console.error('Erreur lors de la création du tag Jira:', error);
// Ne pas faire échouer la sync pour un problème de tag // Ne pas faire échouer la sync pour un problème de tag
@@ -375,7 +363,7 @@ export class JiraService {
this.unknownStatuses.clear(); this.unknownStatuses.clear();
// S'assurer que le tag "From Jira" existe // S'assurer que le tag "From Jira" existe
await this.ensureJiraTagExists(); await this.ensureJiraTagExists(userId);
// Récupérer les tickets Jira actuellement assignés // Récupérer les tickets Jira actuellement assignés
const jiraTasks = await this.getAssignedIssues(); const jiraTasks = await this.getAssignedIssues();
@@ -526,7 +514,7 @@ export class JiraService {
}); });
// Assigner le tag Jira // Assigner le tag Jira
await this.assignJiraTag(newTask.id); await this.assignJiraTag(newTask.id, userId);
console.log( console.log(
` Nouvelle tâche créée: ${jiraTask.key} (status: ${taskData.status})` ` Nouvelle tâche créée: ${jiraTask.key} (status: ${taskData.status})`
@@ -598,7 +586,7 @@ export class JiraService {
); );
// S'assurer que le tag Jira est assigné (pour les anciennes tâches) même en skip // S'assurer que le tag Jira est assigné (pour les anciennes tâches) même en skip
await this.assignJiraTag(existingTask.id); await this.assignJiraTag(existingTask.id, userId);
return { return {
type: 'skipped', type: 'skipped',
@@ -647,7 +635,7 @@ export class JiraService {
}); });
// S'assurer que le tag Jira est assigné (pour les anciennes tâches) // S'assurer que le tag Jira est assigné (pour les anciennes tâches)
await this.assignJiraTag(existingTask.id); await this.assignJiraTag(existingTask.id, userId);
console.log( console.log(
`🔄 Tâche mise à jour (titre/priorité préservés): ${jiraTask.key} (${changes.length} changement${changes.length > 1 ? 's' : ''})` `🔄 Tâche mise à jour (titre/priorité préservés): ${jiraTask.key} (${changes.length} changement${changes.length > 1 ? 's' : ''})`
@@ -756,14 +744,12 @@ export class JiraService {
/** /**
* Assigne le tag "🔗 From Jira" à une tâche si pas déjà assigné * Assigne le tag "🔗 From Jira" à une tâche si pas déjà assigné
*/ */
private async assignJiraTag(taskId: string): Promise<void> { private async assignJiraTag(taskId: string, userId: string): Promise<void> {
try { try {
const tagName = '🔗 From Jira'; const tagName = '🔗 From Jira';
// Récupérer le tag // Utiliser le service tags pour récupérer le tag
const jiraTag = await prisma.tag.findUnique({ const jiraTag = await tagsService.getTagByName(tagName, userId);
where: { name: tagName },
});
if (!jiraTag) { if (!jiraTag) {
console.warn(`⚠️ Tag "${tagName}" introuvable lors de l'assignation`); console.warn(`⚠️ Tag "${tagName}" introuvable lors de l'assignation`);

View File

@@ -8,6 +8,7 @@ import { TfsPullRequest } from '@/lib/types';
import { prisma } from '@/services/core/database'; import { prisma } from '@/services/core/database';
import { parseDate, formatDateForDisplay } from '@/lib/date-utils'; import { parseDate, formatDateForDisplay } from '@/lib/date-utils';
import { userPreferencesService } from '@/services/core/user-preferences'; import { userPreferencesService } from '@/services/core/user-preferences';
import { tagsService } from '../../task-management/tags';
export interface TfsConfig { export interface TfsConfig {
enabled: boolean; enabled: boolean;
@@ -574,7 +575,7 @@ export class TfsService {
console.log('🔄 Début synchronisation TFS Pull Requests...'); console.log('🔄 Début synchronisation TFS Pull Requests...');
// S'assurer que le tag TFS existe // S'assurer que le tag TFS existe
await this.ensureTfsTagExists(); await this.ensureTfsTagExists(userId);
// Récupérer toutes les PRs assignées à l'utilisateur // Récupérer toutes les PRs assignées à l'utilisateur
const allPullRequests = await this.getMyPullRequests(); const allPullRequests = await this.getMyPullRequests();
@@ -665,7 +666,7 @@ export class TfsService {
}); });
// Assigner le tag TFS // Assigner le tag TFS
await this.assignTfsTag(newTask.id); await this.assignTfsTag(newTask.id, userId);
return { return {
type: 'created', type: 'created',
@@ -693,7 +694,7 @@ export class TfsService {
if (changes.length === 0) { if (changes.length === 0) {
// S'assurer que le tag TFS est assigné (pour les anciennes tâches) // S'assurer que le tag TFS est assigné (pour les anciennes tâches)
await this.assignTfsTag(existingTask.id); await this.assignTfsTag(existingTask.id, userId);
return { return {
type: 'skipped', type: 'skipped',
@@ -724,21 +725,11 @@ export class TfsService {
/** /**
* S'assure que le tag TFS existe * S'assure que le tag TFS existe
*/ */
private async ensureTfsTagExists(): Promise<void> { private async ensureTfsTagExists(userId: string): Promise<void> {
try { try {
const existingTag = await prisma.tag.findFirst({ // Utiliser le service tags pour créer ou récupérer le tag
where: { name: '🧑‍💻 TFS' }, await tagsService.ensureTagsExist(['🧑‍💻 TFS'], userId);
}); console.log('✅ Tag TFS créé/récupéré');
if (!existingTag) {
await prisma.tag.create({
data: {
name: '🧑‍💻 TFS',
color: '#0066cc', // Bleu Azure DevOps
},
});
console.log('✅ Tag TFS créé');
}
} catch (error) { } catch (error) {
console.warn('Erreur création tag TFS:', error); console.warn('Erreur création tag TFS:', error);
} }
@@ -747,21 +738,11 @@ export class TfsService {
/** /**
* Assigne automatiquement le tag "TFS" aux tâches importées * Assigne automatiquement le tag "TFS" aux tâches importées
*/ */
private async assignTfsTag(taskId: string): Promise<void> { private async assignTfsTag(taskId: string, userId: string): Promise<void> {
try { try {
let tfsTag = await prisma.tag.findFirst({ // Utiliser le service tags pour créer ou récupérer le tag
where: { name: '🧑‍💻 TFS' }, const tags = await tagsService.ensureTagsExist(['🧑‍💻 TFS'], userId);
}); const tfsTag = tags[0];
if (!tfsTag) {
tfsTag = await prisma.tag.create({
data: {
name: '🧑‍💻 TFS',
color: '#0078d4', // Couleur Azure
isPinned: false,
},
});
}
// Vérifier si la relation existe déjà // Vérifier si la relation existe déjà
const existingRelation = await prisma.taskTag.findFirst({ const existingRelation = await prisma.taskTag.findFirst({

View File

@@ -1,6 +1,7 @@
import { prisma } from '@/services/core/database'; import { prisma } from '@/services/core/database';
import { Task } from '@/lib/types'; import { Task } from '@/lib/types';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { tagsService } from './task-management/tags';
export interface Note { export interface Note {
id: string; id: string;
@@ -178,19 +179,38 @@ export class NotesService {
content: data.content, content: data.content,
userId: data.userId, userId: data.userId,
taskId: data.taskId, // Ajouter le taskId taskId: data.taskId, // Ajouter le taskId
noteTags: data.tags },
? { include: {
create: data.tags.map((tagName) => ({ task: {
tag: { include: {
connectOrCreate: { taskTags: {
where: { name: tagName }, include: {
create: { name: tagName }, tag: true,
}, },
}, },
primaryTag: true,
},
},
},
});
// Créer les relations avec les tags si fournis
if (data.tags && data.tags.length > 0) {
// Créer ou récupérer tous les tags
const tags = await tagsService.ensureTagsExist(data.tags, data.userId);
// Créer les relations NoteTag
await prisma.noteTag.createMany({
data: tags.map((tag) => ({
noteId: note.id,
tagId: tag.id,
})), })),
});
} }
: undefined,
}, // Récupérer la note avec les tags
const noteWithTags = await prisma.note.findUnique({
where: { id: note.id },
include: { include: {
noteTags: { noteTags: {
include: { include: {
@@ -211,10 +231,10 @@ export class NotesService {
}); });
return { return {
...note, ...noteWithTags!,
taskId: note.taskId || undefined, // Convertir null en undefined taskId: noteWithTags!.taskId || undefined, // Convertir null en undefined
task: this.mapPrismaTaskToTask(note.task), // Mapper correctement l'objet Task task: this.mapPrismaTaskToTask(noteWithTags!.task), // Mapper correctement l'objet Task
tags: note.noteTags.map((nt) => nt.tag.name), tags: noteWithTags!.noteTags.map((nt) => nt.tag.name),
}; };
} }
@@ -272,22 +292,34 @@ export class NotesService {
// Gérer les tags si fournis // Gérer les tags si fournis
if (data.tags !== undefined) { if (data.tags !== undefined) {
updateData.noteTags = { // Supprimer toutes les relations existantes
deleteMany: {}, // Supprimer tous les tags existants await prisma.noteTag.deleteMany({
create: data.tags.map((tagName) => ({ where: { noteId: noteId },
tag: { });
connectOrCreate: {
where: { name: tagName }, // Créer les nouvelles relations si des tags sont fournis
create: { name: tagName }, if (data.tags.length > 0) {
}, // Créer ou récupérer tous les tags
}, const tags = await tagsService.ensureTagsExist(data.tags, userId);
// Créer les relations NoteTag
await prisma.noteTag.createMany({
data: tags.map((tag) => ({
noteId: noteId,
tagId: tag.id,
})), })),
}; });
}
} }
const note = await prisma.note.update({ await prisma.note.update({
where: { id: noteId },
data: updateData as Prisma.NoteUpdateInput,
});
// Récupérer la note avec les tags après la mise à jour
const noteWithTags = await prisma.note.findUnique({
where: { id: noteId }, where: { id: noteId },
data: updateData,
include: { include: {
noteTags: { noteTags: {
include: { include: {
@@ -308,10 +340,10 @@ export class NotesService {
}); });
return { return {
...note, ...noteWithTags!,
taskId: note.taskId || undefined, // Convertir null en undefined taskId: noteWithTags!.taskId || undefined, // Convertir null en undefined
task: this.mapPrismaTaskToTask(note.task), // Mapper correctement l'objet Task task: this.mapPrismaTaskToTask(noteWithTags!.task), // Mapper correctement l'objet Task
tags: note.noteTags.map((nt) => nt.tag.name), tags: noteWithTags!.noteTags.map((nt) => nt.tag.name),
}; };
} }

View File

@@ -7,10 +7,11 @@ import { Tag } from '@/lib/types';
*/ */
export const tagsService = { export const tagsService = {
/** /**
* Récupère tous les tags avec leur nombre d'utilisations * Récupère tous les tags d'un utilisateur avec leur nombre d'utilisations
*/ */
async getTags(): Promise<(Tag & { usage: number })[]> { async getTags(userId: string): Promise<(Tag & { usage: number })[]> {
const tags = await prisma.tag.findMany({ const tags = await prisma.tag.findMany({
where: { ownerId: userId },
include: { include: {
_count: { _count: {
select: { select: {
@@ -31,11 +32,14 @@ export const tagsService = {
}, },
/** /**
* Récupère un tag par son ID * Récupère un tag par son ID (vérifie que l'utilisateur en est propriétaire)
*/ */
async getTagById(id: string): Promise<Tag | null> { async getTagById(id: string, userId: string): Promise<Tag | null> {
const tag = await prisma.tag.findUnique({ const tag = await prisma.tag.findFirst({
where: { id }, where: {
id,
ownerId: userId,
},
}); });
if (!tag) return null; if (!tag) return null;
@@ -49,14 +53,15 @@ export const tagsService = {
}, },
/** /**
* Récupère un tag par son nom * Récupère un tag par son nom pour un utilisateur spécifique
*/ */
async getTagByName(name: string): Promise<Tag | null> { async getTagByName(name: string, userId: string): Promise<Tag | null> {
const tag = await prisma.tag.findFirst({ const tag = await prisma.tag.findFirst({
where: { where: {
name: { name: {
equals: name, equals: name,
}, },
ownerId: userId,
}, },
}); });
@@ -71,15 +76,16 @@ export const tagsService = {
}, },
/** /**
* Crée un nouveau tag * Crée un nouveau tag pour un utilisateur
*/ */
async createTag(data: { async createTag(data: {
name: string; name: string;
color: string; color: string;
isPinned?: boolean; isPinned?: boolean;
userId: string;
}): Promise<Tag> { }): Promise<Tag> {
// Vérifier si le tag existe déjà // Vérifier si le tag existe déjà pour cet utilisateur
const existing = await this.getTagByName(data.name); const existing = await this.getTagByName(data.name, data.userId);
if (existing) { if (existing) {
throw new Error(`Un tag avec le nom "${data.name}" existe déjà`); throw new Error(`Un tag avec le nom "${data.name}" existe déjà`);
} }
@@ -89,6 +95,7 @@ export const tagsService = {
name: data.name.trim(), name: data.name.trim(),
color: data.color, color: data.color,
isPinned: data.isPinned || false, isPinned: data.isPinned || false,
ownerId: data.userId,
}, },
}); });
@@ -101,21 +108,24 @@ export const tagsService = {
}, },
/** /**
* Met à jour un tag * Met à jour un tag (vérifie que l'utilisateur en est propriétaire)
*/ */
async updateTag( async updateTag(
id: string, id: string,
userId: string,
data: { name?: string; color?: string; isPinned?: boolean } data: { name?: string; color?: string; isPinned?: boolean }
): Promise<Tag | null> { ): Promise<Tag | null> {
// Vérifier que le tag existe // Vérifier que le tag existe et appartient à l'utilisateur
const existing = await this.getTagById(id); const existing = await this.getTagById(id, userId);
if (!existing) { if (!existing) {
throw new Error(`Tag avec l'ID "${id}" non trouvé`); throw new Error(
`Tag avec l'ID "${id}" non trouvé ou vous n'en êtes pas propriétaire`
);
} }
// Si on change le nom, vérifier qu'il n'existe pas déjà // Si on change le nom, vérifier qu'il n'existe pas déjà pour cet utilisateur
if (data.name && data.name !== existing.name) { if (data.name && data.name !== existing.name) {
const nameExists = await this.getTagByName(data.name); const nameExists = await this.getTagByName(data.name, userId);
if (nameExists && nameExists.id !== id) { if (nameExists && nameExists.id !== id) {
throw new Error(`Un tag avec le nom "${data.name}" existe déjà`); throw new Error(`Un tag avec le nom "${data.name}" existe déjà`);
} }
@@ -150,13 +160,15 @@ export const tagsService = {
}, },
/** /**
* Supprime un tag et toutes ses relations * Supprime un tag et toutes ses relations (vérifie que l'utilisateur en est propriétaire)
*/ */
async deleteTag(id: string): Promise<void> { async deleteTag(id: string, userId: string): Promise<void> {
// Vérifier que le tag existe // Vérifier que le tag existe et appartient à l'utilisateur
const existing = await this.getTagById(id); const existing = await this.getTagById(id, userId);
if (!existing) { if (!existing) {
throw new Error(`Tag avec l'ID "${id}" non trouvé`); throw new Error(
`Tag avec l'ID "${id}" non trouvé ou vous n'en êtes pas propriétaire`
);
} }
// Supprimer d'abord toutes les relations TaskTag // Supprimer d'abord toutes les relations TaskTag
@@ -171,9 +183,10 @@ export const tagsService = {
}, },
/** /**
* Récupère les tags les plus utilisés * Récupère les tags les plus utilisés pour un utilisateur
*/ */
async getPopularTags( async getPopularTags(
userId: string,
limit: number = 10 limit: number = 10
): Promise<Array<Tag & { usage: number }>> { ): Promise<Array<Tag & { usage: number }>> {
// Utiliser une requête SQL brute pour compter les usages // Utiliser une requête SQL brute pour compter les usages
@@ -188,6 +201,7 @@ export const tagsService = {
SELECT t.id, t.name, t.color, COUNT(tt.tagId) as usage SELECT t.id, t.name, t.color, COUNT(tt.tagId) as usage
FROM tags t FROM tags t
LEFT JOIN task_tags tt ON t.id = tt.tagId LEFT JOIN task_tags tt ON t.id = tt.tagId
WHERE t.ownerId = ${userId}
GROUP BY t.id, t.name, t.color GROUP BY t.id, t.name, t.color
ORDER BY usage DESC, t.name ASC ORDER BY usage DESC, t.name ASC
LIMIT ${limit} LIMIT ${limit}
@@ -202,14 +216,19 @@ export const tagsService = {
}, },
/** /**
* Recherche des tags par nom (pour autocomplete) * Recherche des tags par nom pour un utilisateur (pour autocomplete)
*/ */
async searchTags(query: string, limit: number = 10): Promise<Tag[]> { async searchTags(
query: string,
userId: string,
limit: number = 10
): Promise<Tag[]> {
const tags = await prisma.tag.findMany({ const tags = await prisma.tag.findMany({
where: { where: {
name: { name: {
contains: query, contains: query,
}, },
ownerId: userId,
}, },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
take: limit, take: limit,
@@ -224,15 +243,15 @@ export const tagsService = {
}, },
/** /**
* Crée automatiquement des tags manquants à partir d'une liste de noms * Crée automatiquement des tags manquants à partir d'une liste de noms pour un utilisateur
*/ */
async ensureTagsExist(tagNames: string[]): Promise<Tag[]> { async ensureTagsExist(tagNames: string[], userId: string): Promise<Tag[]> {
const results: Tag[] = []; const results: Tag[] = [];
for (const name of tagNames) { for (const name of tagNames) {
if (!name.trim()) continue; if (!name.trim()) continue;
let tag = await this.getTagByName(name.trim()); let tag = await this.getTagByName(name.trim(), userId);
if (!tag) { if (!tag) {
// Générer une couleur aléatoirement // Générer une couleur aléatoirement
@@ -251,6 +270,7 @@ export const tagsService = {
tag = await this.createTag({ tag = await this.createTag({
name: name.trim(), name: name.trim(),
color: randomColor, color: randomColor,
userId,
}); });
} }

View File

@@ -11,7 +11,7 @@ import {
} from '@/lib/types'; } from '@/lib/types';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { getToday } from '@/lib/date-utils'; import { getToday } from '@/lib/date-utils';
import { generateTagColor } from '@/lib/tag-colors'; import { tagsService } from './tags';
/** /**
* Service pour la gestion des tâches (version standalone) * Service pour la gestion des tâches (version standalone)
@@ -112,7 +112,11 @@ export class TasksService {
// Créer les relations avec les tags // Créer les relations avec les tags
if (taskData.tags && taskData.tags.length > 0) { if (taskData.tags && taskData.tags.length > 0) {
await this.createTaskTagRelations(task.id, taskData.tags); await this.createTaskTagRelations(
task.id,
taskData.tags,
taskData.ownerId
);
} }
// Récupérer la tâche avec les tags pour le retour // Récupérer la tâche avec les tags pour le retour
@@ -186,7 +190,7 @@ export class TasksService {
// Mettre à jour les relations avec les tags // Mettre à jour les relations avec les tags
if (updates.tags !== undefined) { if (updates.tags !== undefined) {
await this.updateTaskTagRelations(taskId, updates.tags); await this.updateTaskTagRelations(taskId, updates.tags, userId);
} }
// Récupérer la tâche avec les tags pour le retour // Récupérer la tâche avec les tags pour le retour
@@ -337,19 +341,14 @@ export class TasksService {
*/ */
private async createTaskTagRelations( private async createTaskTagRelations(
taskId: string, taskId: string,
tagNames: string[] tagNames: string[],
userId: string
): Promise<void> { ): Promise<void> {
for (const tagName of tagNames) { for (const tagName of tagNames) {
try { try {
// Créer ou récupérer le tag // Utiliser le service tags pour créer ou récupérer le tag
const tag = await prisma.tag.upsert({ const tags = await tagsService.ensureTagsExist([tagName], userId);
where: { name: tagName }, const tag = tags[0];
update: {}, // Pas de mise à jour nécessaire
create: {
name: tagName,
color: this.generateTagColor(tagName),
},
});
// Créer la relation TaskTag si elle n'existe pas // Créer la relation TaskTag si elle n'existe pas
await prisma.taskTag.upsert({ await prisma.taskTag.upsert({
@@ -379,7 +378,8 @@ export class TasksService {
*/ */
private async updateTaskTagRelations( private async updateTaskTagRelations(
taskId: string, taskId: string,
tagNames: string[] tagNames: string[],
userId: string
): Promise<void> { ): Promise<void> {
// Supprimer toutes les relations existantes // Supprimer toutes les relations existantes
await prisma.taskTag.deleteMany({ await prisma.taskTag.deleteMany({
@@ -388,17 +388,10 @@ export class TasksService {
// Créer les nouvelles relations // Créer les nouvelles relations
if (tagNames.length > 0) { if (tagNames.length > 0) {
await this.createTaskTagRelations(taskId, tagNames); await this.createTaskTagRelations(taskId, tagNames, userId);
} }
} }
/**
* Génère une couleur pour un tag basée sur son nom
*/
private generateTagColor(tagName: string): string {
return generateTagColor(tagName);
}
/** /**
* Convertit une tâche Prisma en objet Task * Convertit une tâche Prisma en objet Task
*/ */