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

View File

@@ -1,7 +1,22 @@
import { PrismaClient } from '@prisma/client';
import { tagsService } from '../src/services/task-management/tags';
const prisma = new PrismaClient();
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 = [
{ name: 'frontend', color: '#3B82F6' },
@@ -19,9 +34,15 @@ async function seedTags() {
for (const tagData of testTags) {
try {
const existing = await tagsService.getTagByName(tagData.name);
const existing = await tagsService.getTagByName(
tagData.name,
firstUser.id
);
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})`);
} else {
console.log(`⚠️ Tag existe déjà: ${tagData.name}`);

View File

@@ -3,6 +3,8 @@
import { tagsService } from '@/services/task-management/tags';
import { revalidatePath } from 'next/cache';
import { Tag } from '@/lib/types';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/lib/auth';
export type ActionResult<T = void> = {
success: boolean;
@@ -18,7 +20,16 @@ export async function createTag(
color: string
): Promise<ActionResult<Tag>> {
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
revalidatePath('/');
@@ -43,7 +54,12 @@ export async function updateTag(
data: { name?: string; color?: string; isPinned?: boolean }
): Promise<ActionResult<Tag>> {
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) {
return { success: false, error: 'Tag non trouvé' };
@@ -69,7 +85,12 @@ export async function updateTag(
*/
export async function deleteTag(tagId: string): Promise<ActionResult> {
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
revalidatePath('/');

View File

@@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
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
@@ -9,8 +11,14 @@ export async function GET(
{ params }: { params: Promise<{ id: string }> }
) {
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 tag = await tagsService.getTagById(id);
const tag = await tagsService.getTagById(id, session.user.id);
if (!tag) {
return NextResponse.json({ error: 'Tag non trouvé' }, { status: 404 });

View File

@@ -1,11 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
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
*/
export async function GET(request: NextRequest) {
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 query = searchParams.get('q');
const popular = searchParams.get('popular');
@@ -14,14 +22,14 @@ export async function GET(request: NextRequest) {
let tags;
if (popular === 'true') {
// Récupérer les tags les plus utilisés
tags = await tagsService.getPopularTags(limit);
// Récupérer les tags les plus utilisés pour cet utilisateur
tags = await tagsService.getPopularTags(session.user.id, limit);
} else if (query) {
// Recherche par nom (pour autocomplete)
tags = await tagsService.searchTags(query, limit);
// Recherche par nom (pour autocomplete) pour cet utilisateur
tags = await tagsService.searchTags(query, session.user.id, limit);
} else {
// Récupérer tous les tags
tags = await tagsService.getTags();
// Récupérer tous les tags de cet utilisateur
tags = await tagsService.getTags(session.user.id);
}
return NextResponse.json({

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,31 @@
import { tagsService } from '@/services/task-management/tags';
import { GeneralSettingsPageClient } from '@/components/settings/GeneralSettingsPageClient';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/lib/auth';
// Force dynamic rendering for real-time data
export const dynamic = 'force-dynamic';
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
const tags = await tagsService.getTags();
const tags = await tagsService.getTags(session.user.id);
return <GeneralSettingsPageClient initialTags={tags} />;
}

View File

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

View File

@@ -6,6 +6,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';
export interface JiraConfig {
enabled: boolean;
@@ -326,26 +327,13 @@ export class JiraService {
/**
* S'assure que le tag "🔗 From Jira" existe dans la base
*/
private async ensureJiraTagExists(): Promise<void> {
private async ensureJiraTagExists(userId: string): Promise<void> {
try {
const tagName = '🔗 From Jira';
// Vérifier si le tag existe déjà
const existingTag = await prisma.tag.findUnique({
where: { name: tagName },
});
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`);
}
// Utiliser le service tags pour créer ou récupérer le tag
await tagsService.ensureTagsExist([tagName], userId);
console.log(`✅ Tag "${tagName}" créé/récupéré automatiquement`);
} catch (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
@@ -375,7 +363,7 @@ export class JiraService {
this.unknownStatuses.clear();
// S'assurer que le tag "From Jira" existe
await this.ensureJiraTagExists();
await this.ensureJiraTagExists(userId);
// Récupérer les tickets Jira actuellement assignés
const jiraTasks = await this.getAssignedIssues();
@@ -526,7 +514,7 @@ export class JiraService {
});
// Assigner le tag Jira
await this.assignJiraTag(newTask.id);
await this.assignJiraTag(newTask.id, userId);
console.log(
` 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
await this.assignJiraTag(existingTask.id);
await this.assignJiraTag(existingTask.id, userId);
return {
type: 'skipped',
@@ -647,7 +635,7 @@ export class JiraService {
});
// 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(
`🔄 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é
*/
private async assignJiraTag(taskId: string): Promise<void> {
private async assignJiraTag(taskId: string, userId: string): Promise<void> {
try {
const tagName = '🔗 From Jira';
// Récupérer le tag
const jiraTag = await prisma.tag.findUnique({
where: { name: tagName },
});
// Utiliser le service tags pour récupérer le tag
const jiraTag = await tagsService.getTagByName(tagName, userId);
if (!jiraTag) {
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 { parseDate, formatDateForDisplay } from '@/lib/date-utils';
import { userPreferencesService } from '@/services/core/user-preferences';
import { tagsService } from '../../task-management/tags';
export interface TfsConfig {
enabled: boolean;
@@ -574,7 +575,7 @@ export class TfsService {
console.log('🔄 Début synchronisation TFS Pull Requests...');
// S'assurer que le tag TFS existe
await this.ensureTfsTagExists();
await this.ensureTfsTagExists(userId);
// Récupérer toutes les PRs assignées à l'utilisateur
const allPullRequests = await this.getMyPullRequests();
@@ -665,7 +666,7 @@ export class TfsService {
});
// Assigner le tag TFS
await this.assignTfsTag(newTask.id);
await this.assignTfsTag(newTask.id, userId);
return {
type: 'created',
@@ -693,7 +694,7 @@ export class TfsService {
if (changes.length === 0) {
// 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 {
type: 'skipped',
@@ -724,21 +725,11 @@ export class TfsService {
/**
* S'assure que le tag TFS existe
*/
private async ensureTfsTagExists(): Promise<void> {
private async ensureTfsTagExists(userId: string): Promise<void> {
try {
const existingTag = await prisma.tag.findFirst({
where: { name: '🧑‍💻 TFS' },
});
if (!existingTag) {
await prisma.tag.create({
data: {
name: '🧑‍💻 TFS',
color: '#0066cc', // Bleu Azure DevOps
},
});
console.log('✅ Tag TFS créé');
}
// Utiliser le service tags pour créer ou récupérer le tag
await tagsService.ensureTagsExist(['🧑‍💻 TFS'], userId);
console.log('✅ Tag TFS créé/récupéré');
} catch (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
*/
private async assignTfsTag(taskId: string): Promise<void> {
private async assignTfsTag(taskId: string, userId: string): Promise<void> {
try {
let tfsTag = await prisma.tag.findFirst({
where: { name: '🧑‍💻 TFS' },
});
if (!tfsTag) {
tfsTag = await prisma.tag.create({
data: {
name: '🧑‍💻 TFS',
color: '#0078d4', // Couleur Azure
isPinned: false,
},
});
}
// Utiliser le service tags pour créer ou récupérer le tag
const tags = await tagsService.ensureTagsExist(['🧑‍💻 TFS'], userId);
const tfsTag = tags[0];
// Vérifier si la relation existe déjà
const existingRelation = await prisma.taskTag.findFirst({

View File

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

View File

@@ -7,10 +7,11 @@ import { Tag } from '@/lib/types';
*/
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({
where: { ownerId: userId },
include: {
_count: {
select: {
@@ -31,32 +32,13 @@ 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> {
const tag = await prisma.tag.findUnique({
where: { id },
});
if (!tag) return null;
return {
id: tag.id,
name: tag.name,
color: tag.color,
isPinned: tag.isPinned,
};
},
/**
* Récupère un tag par son nom
*/
async getTagByName(name: string): Promise<Tag | null> {
async getTagById(id: string, userId: string): Promise<Tag | null> {
const tag = await prisma.tag.findFirst({
where: {
name: {
equals: name,
},
id,
ownerId: userId,
},
});
@@ -71,15 +53,39 @@ export const tagsService = {
},
/**
* Crée un nouveau tag
* Récupère un tag par son nom pour un utilisateur spécifique
*/
async getTagByName(name: string, userId: string): Promise<Tag | null> {
const tag = await prisma.tag.findFirst({
where: {
name: {
equals: name,
},
ownerId: userId,
},
});
if (!tag) return null;
return {
id: tag.id,
name: tag.name,
color: tag.color,
isPinned: tag.isPinned,
};
},
/**
* Crée un nouveau tag pour un utilisateur
*/
async createTag(data: {
name: string;
color: string;
isPinned?: boolean;
userId: string;
}): Promise<Tag> {
// Vérifier si le tag existe déjà
const existing = await this.getTagByName(data.name);
// Vérifier si le tag existe déjà pour cet utilisateur
const existing = await this.getTagByName(data.name, data.userId);
if (existing) {
throw new Error(`Un tag avec le nom "${data.name}" existe déjà`);
}
@@ -89,6 +95,7 @@ export const tagsService = {
name: data.name.trim(),
color: data.color,
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(
id: string,
userId: string,
data: { name?: string; color?: string; isPinned?: boolean }
): Promise<Tag | null> {
// Vérifier que le tag existe
const existing = await this.getTagById(id);
// Vérifier que le tag existe et appartient à l'utilisateur
const existing = await this.getTagById(id, userId);
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) {
const nameExists = await this.getTagByName(data.name);
const nameExists = await this.getTagByName(data.name, userId);
if (nameExists && nameExists.id !== id) {
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> {
// Vérifier que le tag existe
const existing = await this.getTagById(id);
async deleteTag(id: string, userId: string): Promise<void> {
// Vérifier que le tag existe et appartient à l'utilisateur
const existing = await this.getTagById(id, userId);
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
@@ -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(
userId: string,
limit: number = 10
): Promise<Array<Tag & { usage: number }>> {
// 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
FROM tags t
LEFT JOIN task_tags tt ON t.id = tt.tagId
WHERE t.ownerId = ${userId}
GROUP BY t.id, t.name, t.color
ORDER BY usage DESC, t.name ASC
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({
where: {
name: {
contains: query,
},
ownerId: userId,
},
orderBy: { name: 'asc' },
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[] = [];
for (const name of tagNames) {
if (!name.trim()) continue;
let tag = await this.getTagByName(name.trim());
let tag = await this.getTagByName(name.trim(), userId);
if (!tag) {
// Générer une couleur aléatoirement
@@ -251,6 +270,7 @@ export const tagsService = {
tag = await this.createTag({
name: name.trim(),
color: randomColor,
userId,
});
}

View File

@@ -11,7 +11,7 @@ import {
} from '@/lib/types';
import { Prisma } from '@prisma/client';
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)
@@ -112,7 +112,11 @@ export class TasksService {
// Créer les relations avec les tags
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
@@ -186,7 +190,7 @@ export class TasksService {
// Mettre à jour les relations avec les tags
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
@@ -337,19 +341,14 @@ export class TasksService {
*/
private async createTaskTagRelations(
taskId: string,
tagNames: string[]
tagNames: string[],
userId: string
): Promise<void> {
for (const tagName of tagNames) {
try {
// Créer ou récupérer le tag
const tag = await prisma.tag.upsert({
where: { name: tagName },
update: {}, // Pas de mise à jour nécessaire
create: {
name: tagName,
color: this.generateTagColor(tagName),
},
});
// Utiliser le service tags pour créer ou récupérer le tag
const tags = await tagsService.ensureTagsExist([tagName], userId);
const tag = tags[0];
// Créer la relation TaskTag si elle n'existe pas
await prisma.taskTag.upsert({
@@ -379,7 +378,8 @@ export class TasksService {
*/
private async updateTaskTagRelations(
taskId: string,
tagNames: string[]
tagNames: string[],
userId: string
): Promise<void> {
// Supprimer toutes les relations existantes
await prisma.taskTag.deleteMany({
@@ -388,17 +388,10 @@ export class TasksService {
// Créer les nouvelles relations
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
*/