diff --git a/.cursor/rules/front_vue.mdc b/.cursor/rules/front_vue.mdc index a724464..5e5625d 100644 --- a/.cursor/rules/front_vue.mdc +++ b/.cursor/rules/front_vue.mdc @@ -1,6 +1,6 @@ --- description: -globs: *.vue,*.js,assets/vue/app/* +globs: *.vue,*.js,assets/vue/app/*,assets/vue/app/**/* alwaysApply: false --- # Architecture Frontend Vue.js diff --git a/assets/vue/app/domain/activity/application/store/activityStore.js b/assets/vue/app/domain/activity/application/store/activityStore.js new file mode 100644 index 0000000..23176c0 --- /dev/null +++ b/assets/vue/app/domain/activity/application/store/activityStore.js @@ -0,0 +1,124 @@ +import { defineStore } from 'pinia'; +import { ApiJobRepository } from '../../infrastructure/api/ApiJobRepository'; + +const jobRepository = new ApiJobRepository(); + +export const useActivityStore = defineStore('activity', { + state: () => ({ + jobs: [], + loading: false, + error: null, + filter: { + status: ['pending', 'in_progress', 'failed'], // Par défaut, afficher les jobs actifs + sortBy: 'createdAt', + sortOrder: 'DESC' + } + }), + + getters: { + activeJobs: state => state.jobs.filter(job => job.isActive()), + completedJobs: state => state.jobs.filter(job => job.isCompleted()), + failedJobs: state => state.jobs.filter(job => job.hasError()), + isLoading: state => state.loading, + hasError: state => !!state.error + }, + + actions: { + /** + * Charge la liste des jobs selon les filtres actuels + */ + async loadJobs() { + this.loading = true; + this.error = null; + + try { + const options = { + page: 1, // Page fixée à 1 + limit: 100, // Limite augmentée pour récupérer plus de jobs + sortBy: this.filter.sortBy, + sortOrder: this.filter.sortOrder, + status: this.filter.status + }; + + const jobCollection = await jobRepository.getJobs(options); + this.jobs = jobCollection.items; + } catch (error) { + this.error = error.message; + console.error('Error loading jobs:', error); + } finally { + this.loading = false; + } + }, + + /** + * Met à jour les filtres et recharge la liste + * @param {Object} filter + */ + async updateFilter(filter) { + this.filter = { ...this.filter, ...filter }; + await this.loadJobs(); + }, + + /** + * Supprime un job par son ID + * @param {string} id + */ + async deleteJob(id) { + this.loading = true; + this.error = null; + + try { + await jobRepository.deleteJob(id); + // Supprimer le job de la liste locale + this.jobs = this.jobs.filter(job => job.id !== id); + } catch (error) { + this.error = error.message; + console.error('Error deleting job:', error); + } finally { + this.loading = false; + } + }, + + /** + * Supprime tous les jobs correspondant aux critères + * @param {Object} criteria + */ + async deleteJobs(criteria = {}) { + this.loading = true; + this.error = null; + + try { + const deleted = await jobRepository.deleteJobs(criteria); + // Recharger la liste après suppression + await this.loadJobs(); + return deleted; + } catch (error) { + this.error = error.message; + console.error('Error deleting jobs:', error); + } finally { + this.loading = false; + } + }, + + /** + * Supprime tous les jobs terminés + */ + async deleteCompletedJobs() { + return this.deleteJobs({ status: ['COMPLETED'] }); + }, + + /** + * Supprime tous les jobs en erreur + */ + async deleteFailedJobs() { + return this.deleteJobs({ status: ['ERROR'] }); + }, + + /** + * Supprime tous les jobs + */ + async deleteAllJobs() { + return this.deleteJobs({}); + } + } +}); diff --git a/assets/vue/app/domain/activity/domain/entities/job.js b/assets/vue/app/domain/activity/domain/entities/job.js new file mode 100644 index 0000000..56eec2e --- /dev/null +++ b/assets/vue/app/domain/activity/domain/entities/job.js @@ -0,0 +1,50 @@ +export class Job { + constructor({ + id, + type, + status, + progress = 0, + payload = {}, + result = null, + error = null, + createdAt = new Date().toISOString(), + updatedAt = new Date().toISOString() + }) { + this.id = id; + this.type = type; + this.status = status; + this.progress = progress; + this.payload = payload; + this.result = result; + this.error = error; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + static create(data) { + return new Job(data); + } + + isActive() { + return ['PENDING', 'IN_PROGRESS'].includes(this.status); + } + + hasError() { + return this.status === 'ERROR'; + } + + isCompleted() { + return this.status === 'COMPLETED'; + } +} + +export class JobCollection { + constructor(items, total, page, limit, hasNextPage, hasPreviousPage) { + this.items = items.map(item => Job.create(item)); + this.total = total; + this.page = page; + this.limit = limit; + this.hasNextPage = hasNextPage; + this.hasPreviousPage = hasPreviousPage; + } +} diff --git a/assets/vue/app/domain/activity/domain/repository/JobRepositoryInterface.js b/assets/vue/app/domain/activity/domain/repository/JobRepositoryInterface.js new file mode 100644 index 0000000..3afd574 --- /dev/null +++ b/assets/vue/app/domain/activity/domain/repository/JobRepositoryInterface.js @@ -0,0 +1,42 @@ +export class JobRepositoryInterface { + /** + * Récupère la liste des jobs + * @param {Object} options Les options de filtrage et pagination + * @param {number} options.page Numéro de la page + * @param {number} options.limit Nombre d'éléments par page + * @param {string} options.sortBy Champ pour le tri + * @param {string} options.sortOrder Direction du tri ('ASC' ou 'DESC') + * @param {Array} options.status Liste des statuts à filtrer + * @returns {Promise} Collection de jobs + */ + async getJobs(options) { + throw new Error('Not implemented'); + } + + /** + * Récupère un job par son ID + * @param {string} id Identifiant du job + * @returns {Promise} Job + */ + async getJobById(id) { + throw new Error('Not implemented'); + } + + /** + * Supprime un job + * @param {string} id Identifiant du job + * @returns {Promise} Succès de l'opération + */ + async deleteJob(id) { + throw new Error('Not implemented'); + } + + /** + * Supprime tous les jobs correspondant aux critères + * @param {Object} criteria Critères de suppression + * @returns {Promise} Nombre de jobs supprimés + */ + async deleteJobs(criteria) { + throw new Error('Not implemented'); + } +} diff --git a/assets/vue/app/domain/activity/infrastructure/api/ApiJobRepository.js b/assets/vue/app/domain/activity/infrastructure/api/ApiJobRepository.js new file mode 100644 index 0000000..04e5c6a --- /dev/null +++ b/assets/vue/app/domain/activity/infrastructure/api/ApiJobRepository.js @@ -0,0 +1,124 @@ +import { Job, JobCollection } from '../../domain/entities/job'; +import { JobRepositoryInterface } from '../../domain/repository/JobRepositoryInterface'; + +export class ApiJobRepository extends JobRepositoryInterface { + /** + * Récupère la liste des jobs + * @param {Object} options Les options de filtrage et pagination + * @param {number} options.page Numéro de la page + * @param {number} options.limit Nombre d'éléments par page + * @param {string} options.sortBy Champ pour le tri + * @param {string} options.sortOrder Direction du tri ('ASC' ou 'DESC') + * @param {Array} options.status Liste des statuts à filtrer + * @returns {Promise} Collection de jobs + */ + async getJobs(options = {}) { + const { page = 1, limit = 100, sortBy = 'createdAt', sortOrder = 'DESC', status = [] } = options; + + try { + let url = `/api/jobs?page=${page}&limit=${limit}&sortBy=${sortBy}&sortOrder=${sortOrder}`; + + // Ajouter les filtres de statut s'ils sont fournis + if (status && status.length > 0) { + url += `&status=${status.join(',')}`; + } + + const response = await fetch(url); + + if (!response.ok) { + throw new Error('Failed to fetch jobs'); + } + + const data = await response.json(); + + return new JobCollection( + data.items || [], + data.total || 0, + data.page || 1, + data.limit || limit, + !!data.hasNextPage, + !!data.hasPreviousPage + ); + } catch (error) { + console.error('API Error:', error); + throw error; + } + } + + /** + * Récupère un job par son ID + * @param {string} id Identifiant du job + * @returns {Promise} Job + */ + async getJobById(id) { + try { + const response = await fetch(`/api/jobs/${id}`); + + if (!response.ok) { + throw new Error('Failed to fetch job'); + } + + const data = await response.json(); + return Job.create(data); + } catch (error) { + console.error('API Error:', error); + throw error; + } + } + + /** + * Supprime un job + * @param {string} id Identifiant du job + * @returns {Promise} Succès de l'opération + */ + async deleteJob(id) { + try { + const response = await fetch(`/api/jobs/${id}`, { + method: 'DELETE' + }); + + if (!response.ok) { + throw new Error('Failed to delete job'); + } + + return true; + } catch (error) { + console.error('API Error:', error); + throw error; + } + } + + /** + * Supprime tous les jobs correspondant aux critères + * @param {Object} criteria Critères de suppression + * @returns {Promise} Nombre de jobs supprimés + */ + async deleteJobs(criteria = {}) { + try { + const queryParams = new URLSearchParams(); + + // Ajouter les critères à l'URL + Object.entries(criteria).forEach(([key, value]) => { + if (Array.isArray(value)) { + queryParams.append(key, value.join(',')); + } else { + queryParams.append(key, value); + } + }); + + const response = await fetch(`/api/jobs?${queryParams.toString()}`, { + method: 'DELETE' + }); + + if (!response.ok) { + throw new Error('Failed to delete jobs'); + } + + const data = await response.json(); + return data.deleted || 0; + } catch (error) { + console.error('API Error:', error); + throw error; + } + } +} diff --git a/assets/vue/app/domain/activity/presentation/components/JobItem.vue b/assets/vue/app/domain/activity/presentation/components/JobItem.vue new file mode 100644 index 0000000..8054088 --- /dev/null +++ b/assets/vue/app/domain/activity/presentation/components/JobItem.vue @@ -0,0 +1,102 @@ + + + diff --git a/assets/vue/app/domain/activity/presentation/pages/ActivityPage.vue b/assets/vue/app/domain/activity/presentation/pages/ActivityPage.vue new file mode 100644 index 0000000..366e0a7 --- /dev/null +++ b/assets/vue/app/domain/activity/presentation/pages/ActivityPage.vue @@ -0,0 +1,142 @@ + + + diff --git a/assets/vue/app/domain/manga/presentation/pages/HomePage.vue b/assets/vue/app/domain/manga/presentation/pages/HomePage.vue index 03204d9..7eae228 100644 --- a/assets/vue/app/domain/manga/presentation/pages/HomePage.vue +++ b/assets/vue/app/domain/manga/presentation/pages/HomePage.vue @@ -1,60 +1,85 @@ diff --git a/assets/vue/app/router/index.js b/assets/vue/app/router/index.js index 0c2517a..415a626 100644 --- a/assets/vue/app/router/index.js +++ b/assets/vue/app/router/index.js @@ -3,6 +3,7 @@ import Layout from '../shared/components/layout/Layout.vue'; import HomePage from '../domain/manga/presentation/pages/HomePage.vue'; import MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue'; import ChapterPage from '../domain/reader/presentation/pages/ChapterPage.vue'; +import ActivityPage from '../domain/activity/presentation/pages/ActivityPage.vue'; // Placeholder component for new routes const PlaceholderComponent = { @@ -80,8 +81,7 @@ const routes = [ { path: '/activity', name: 'activity', - component: PlaceholderComponent, - props: { title: 'Activité' } + component: ActivityPage }, // Paramètres { diff --git a/assets/vue/app/shared/components/ui/Divider.vue b/assets/vue/app/shared/components/ui/Divider.vue new file mode 100644 index 0000000..42ce174 --- /dev/null +++ b/assets/vue/app/shared/components/ui/Divider.vue @@ -0,0 +1,7 @@ + + + diff --git a/assets/vue/app/shared/components/ui/Toolbar.vue b/assets/vue/app/shared/components/ui/Toolbar.vue index 339f00c..19ba376 100644 --- a/assets/vue/app/shared/components/ui/Toolbar.vue +++ b/assets/vue/app/shared/components/ui/Toolbar.vue @@ -1,47 +1,26 @@ \ No newline at end of file + import ToolbarSection from './ToolbarSection.vue'; + + defineProps({ + config: { + type: Object, + required: true, + validator: value => { + // Vérifie que leftSection et rightSection sont des tableaux + return Array.isArray(value.leftSection) && Array.isArray(value.rightSection); + } + } + }); + diff --git a/assets/vue/app/shared/components/ui/ToolbarButton.vue b/assets/vue/app/shared/components/ui/ToolbarButton.vue new file mode 100644 index 0000000..3eca4f0 --- /dev/null +++ b/assets/vue/app/shared/components/ui/ToolbarButton.vue @@ -0,0 +1,37 @@ + + + diff --git a/assets/vue/app/shared/components/ui/ToolbarDropdown.vue b/assets/vue/app/shared/components/ui/ToolbarDropdown.vue new file mode 100644 index 0000000..92f1e3a --- /dev/null +++ b/assets/vue/app/shared/components/ui/ToolbarDropdown.vue @@ -0,0 +1,67 @@ + + + diff --git a/assets/vue/app/shared/components/ui/ToolbarSection.vue b/assets/vue/app/shared/components/ui/ToolbarSection.vue new file mode 100644 index 0000000..7671669 --- /dev/null +++ b/assets/vue/app/shared/components/ui/ToolbarSection.vue @@ -0,0 +1,44 @@ + + + diff --git a/public/api-docs.json b/public/api-docs.json index da1c507..5865ec5 100644 --- a/public/api-docs.json +++ b/public/api-docs.json @@ -12,6 +12,347 @@ } ], "paths": { + "/api/jobs": { + "get": { + "operationId": "api_jobs_get_collection", + "tags": [ + "Job" + ], + "responses": { + "200": { + "description": "Job collection", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Job" + } + } + }, + "application/ld+json": { + "schema": { + "type": "object", + "properties": { + "hydra:member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Job.jsonld" + } + }, + "hydra:totalItems": { + "type": "integer", + "minimum": 0 + }, + "hydra:view": { + "type": "object", + "properties": { + "@id": { + "type": "string", + "format": "iri-reference" + }, + "@type": { + "type": "string" + }, + "hydra:first": { + "type": "string", + "format": "iri-reference" + }, + "hydra:last": { + "type": "string", + "format": "iri-reference" + }, + "hydra:previous": { + "type": "string", + "format": "iri-reference" + }, + "hydra:next": { + "type": "string", + "format": "iri-reference" + } + }, + "example": { + "@id": "string", + "type": "string", + "hydra:first": "string", + "hydra:last": "string", + "hydra:previous": "string", + "hydra:next": "string" + } + }, + "hydra:search": { + "type": "object", + "properties": { + "@type": { + "type": "string" + }, + "hydra:template": { + "type": "string" + }, + "hydra:variableRepresentation": { + "type": "string" + }, + "hydra:mapping": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@type": { + "type": "string" + }, + "variable": { + "type": "string" + }, + "property": { + "type": [ + "string", + "null" + ] + }, + "required": { + "type": "boolean" + } + } + } + } + } + } + }, + "required": [ + "hydra:member" + ] + } + }, + "text/html": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Job" + } + } + }, + "application/hal+json": { + "schema": { + "type": "object", + "properties": { + "_embedded": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Job.jsonhal" + } + }, + "totalItems": { + "type": "integer", + "minimum": 0 + }, + "itemsPerPage": { + "type": "integer", + "minimum": 0 + }, + "_links": { + "type": "object", + "properties": { + "self": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "iri-reference" + } + } + }, + "first": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "iri-reference" + } + } + }, + "last": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "iri-reference" + } + } + }, + "next": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "iri-reference" + } + } + }, + "previous": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "iri-reference" + } + } + } + } + } + }, + "required": [ + "_links", + "_embedded" + ] + } + } + } + } + }, + "summary": "Retrieves the collection of Job resources.", + "description": "Retrieves the collection of Job resources.", + "parameters": [ + { + "name": "status", + "in": "query", + "description": "Filtrer par status", + "required": false, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "string", + "enum": [ + "pending", + "in_progress", + "completed", + "failed" + ], + "example": "pending" + }, + "style": "form", + "explode": false, + "allowReserved": false + }, + { + "name": "type", + "in": "query", + "description": "Filtrer par type de job (ex: scraping_job)", + "required": false, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": false, + "allowReserved": false + }, + { + "name": "createdAfter", + "in": "query", + "description": "Date de cr\u00e9ation minimum (format ISO8601)", + "required": false, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "string", + "format": "date-time" + }, + "style": "form", + "explode": false, + "allowReserved": false + }, + { + "name": "createdBefore", + "in": "query", + "description": "Date de cr\u00e9ation maximum (format ISO8601)", + "required": false, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "string", + "format": "date-time" + }, + "style": "form", + "explode": false, + "allowReserved": false + }, + { + "name": "page", + "in": "query", + "description": "Num\u00e9ro de la page", + "required": false, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "integer", + "default": 1, + "minimum": 1 + }, + "style": "form", + "explode": false, + "allowReserved": false + }, + { + "name": "limit", + "in": "query", + "description": "Nombre d'\u00e9l\u00e9ments par page", + "required": false, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "integer", + "default": 20, + "minimum": 1 + }, + "style": "form", + "explode": false, + "allowReserved": false + }, + { + "name": "sortBy", + "in": "query", + "description": "Champ de tri", + "required": false, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "string", + "enum": [ + "createdAt", + "type", + "status" + ], + "default": "createdAt" + }, + "style": "form", + "explode": false, + "allowReserved": false + }, + { + "name": "sortOrder", + "in": "query", + "description": "Ordre de tri", + "required": false, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "string", + "enum": [ + "ASC", + "DESC" + ], + "default": "DESC" + }, + "style": "form", + "explode": false, + "allowReserved": false + } + ], + "deprecated": false + }, + "parameters": [] + }, "/api/manga/chapters/fetch": { "post": { "operationId": "api_mangachaptersfetch_post", @@ -596,30 +937,24 @@ "description": "Creates a new manga by fetching its data from Mangadex using an external ID", "parameters": [], "requestBody": { - "description": "The new Manga resource", + "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Manga" - } - }, - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Manga.jsonld" - } - }, - "text/html": { - "schema": { - "$ref": "#/components/schemas/Manga" - } - }, - "application/hal+json": { - "schema": { - "$ref": "#/components/schemas/Manga.jsonhal" + "type": "object", + "required": [ + "externalId" + ], + "properties": { + "externalId": { + "type": "string", + "description": "The Mangadex ID of the manga" + } + } } } }, - "required": true + "required": false }, "deprecated": false }, @@ -1177,64 +1512,6 @@ "deprecated": false }, "parameters": [] - }, - "/api/scraping/jobs/{jobId}/status": { - "get": { - "operationId": "api_scrapingjobs_jobIdstatus_get", - "tags": [ - "Scraping" - ], - "responses": { - "200": { - "description": "Scraping resource", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Scraping" - } - }, - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Scraping.jsonld" - } - }, - "text/html": { - "schema": { - "$ref": "#/components/schemas/Scraping" - } - }, - "application/hal+json": { - "schema": { - "$ref": "#/components/schemas/Scraping.jsonhal" - } - } - } - }, - "404": { - "description": "Resource not found" - } - }, - "summary": "Retrieves a Scraping resource.", - "description": "Retrieves a Scraping resource.", - "parameters": [ - { - "name": "jobId", - "in": "path", - "description": "ScrapingStatusResponse identifier", - "required": true, - "deprecated": false, - "allowEmptyValue": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false, - "allowReserved": false - } - ], - "deprecated": false - }, - "parameters": [] } }, "components": { @@ -1265,6 +1542,9 @@ "isVisible": { "type": "boolean" }, + "isAvailable": { + "type": "boolean" + }, "createdAt": { "type": "string" } @@ -1296,6 +1576,9 @@ "isVisible": { "type": "boolean" }, + "isAvailable": { + "type": "boolean" + }, "createdAt": { "type": "string" } @@ -1362,11 +1645,264 @@ "isVisible": { "type": "boolean" }, + "isAvailable": { + "type": "boolean" + }, "createdAt": { "type": "string" } } }, + "Job": { + "type": "object", + "description": "Liste des jobs", + "deprecated": false, + "properties": { + "id": { + "description": "Identifiant unique du job", + "type": "string" + }, + "type": { + "description": "Type du job (ex: scraping_job)", + "type": "string" + }, + "status": { + "description": "Status du job", + "type": "string", + "enum": [ + "pending", + "in_progress", + "completed", + "failed", + "cancelled" + ] + }, + "createdAt": { + "description": "Date de cr\u00e9ation du job", + "type": "string", + "format": "date-time" + }, + "startedAt": { + "description": "Date de d\u00e9but d'ex\u00e9cution du job", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "completedAt": { + "description": "Date de fin d'ex\u00e9cution du job", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "failureReason": { + "description": "Raison de l'\u00e9chec si le job a \u00e9chou\u00e9", + "type": [ + "string", + "null" + ] + }, + "attempts": { + "minimum": 0, + "description": "Nombre de tentatives effectu\u00e9es", + "type": "integer" + }, + "maxAttempts": { + "exclusiveMinimum": 0, + "description": "Nombre maximum de tentatives autoris\u00e9es", + "type": "integer" + }, + "context": { + "description": "Donn\u00e9es contextuelles du job", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "status", + "createdAt" + ] + }, + "Job.jsonhal": { + "type": "object", + "description": "Liste des jobs", + "deprecated": false, + "properties": { + "_links": { + "type": "object", + "properties": { + "self": { + "type": "object", + "properties": { + "href": { + "type": "string", + "format": "iri-reference" + } + } + } + } + }, + "id": { + "description": "Identifiant unique du job", + "type": "string" + }, + "type": { + "description": "Type du job (ex: scraping_job)", + "type": "string" + }, + "status": { + "description": "Status du job", + "type": "string", + "enum": [ + "pending", + "in_progress", + "completed", + "failed", + "cancelled" + ] + }, + "createdAt": { + "description": "Date de cr\u00e9ation du job", + "type": "string", + "format": "date-time" + }, + "startedAt": { + "description": "Date de d\u00e9but d'ex\u00e9cution du job", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "completedAt": { + "description": "Date de fin d'ex\u00e9cution du job", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "failureReason": { + "description": "Raison de l'\u00e9chec si le job a \u00e9chou\u00e9", + "type": [ + "string", + "null" + ] + }, + "attempts": { + "minimum": 0, + "description": "Nombre de tentatives effectu\u00e9es", + "type": "integer" + }, + "maxAttempts": { + "exclusiveMinimum": 0, + "description": "Nombre maximum de tentatives autoris\u00e9es", + "type": "integer" + }, + "context": { + "description": "Donn\u00e9es contextuelles du job", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "status", + "createdAt" + ] + }, + "Job.jsonld": { + "type": "object", + "description": "Liste des jobs", + "deprecated": false, + "properties": { + "@id": { + "readOnly": true, + "type": "string" + }, + "@type": { + "readOnly": true, + "type": "string" + }, + "id": { + "description": "Identifiant unique du job", + "type": "string" + }, + "type": { + "description": "Type du job (ex: scraping_job)", + "type": "string" + }, + "status": { + "description": "Status du job", + "type": "string", + "enum": [ + "pending", + "in_progress", + "completed", + "failed", + "cancelled" + ] + }, + "createdAt": { + "description": "Date de cr\u00e9ation du job", + "type": "string", + "format": "date-time" + }, + "startedAt": { + "description": "Date de d\u00e9but d'ex\u00e9cution du job", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "completedAt": { + "description": "Date de fin d'ex\u00e9cution du job", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "failureReason": { + "description": "Raison de l'\u00e9chec si le job a \u00e9chou\u00e9", + "type": [ + "string", + "null" + ] + }, + "attempts": { + "minimum": 0, + "description": "Nombre de tentatives effectu\u00e9es", + "type": "integer" + }, + "maxAttempts": { + "exclusiveMinimum": 0, + "description": "Nombre maximum de tentatives autoris\u00e9es", + "type": "integer" + }, + "context": { + "description": "Donn\u00e9es contextuelles du job", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "status", + "createdAt" + ] + }, "Manga": { "type": "object", "description": "", @@ -2284,6 +2820,12 @@ "null" ] }, + "thumbnailUrl": { + "type": [ + "string", + "null" + ] + }, "author": { "type": "string" }, @@ -2334,6 +2876,12 @@ "null" ] }, + "thumbnailUrl": { + "type": [ + "string", + "null" + ] + }, "author": { "type": "string" }, @@ -2419,6 +2967,12 @@ "null" ] }, + "thumbnailUrl": { + "type": [ + "string", + "null" + ] + }, "author": { "type": "string" }, diff --git a/src/Domain/Shared/Application/Query/ListJobsQuery.php b/src/Domain/Shared/Application/Query/ListJobsQuery.php index edd187d..80fda89 100644 --- a/src/Domain/Shared/Application/Query/ListJobsQuery.php +++ b/src/Domain/Shared/Application/Query/ListJobsQuery.php @@ -9,8 +9,11 @@ use App\Domain\Shared\Domain\Model\JobStatus; readonly class ListJobsQuery implements QueryInterface { + /** + * @param JobStatus[] $statuses + */ public function __construct( - public ?JobStatus $status = null, + public ?array $statuses = null, public ?string $type = null, public ?\DateTimeImmutable $createdAfter = null, public ?\DateTimeImmutable $createdBefore = null, diff --git a/src/Domain/Shared/Application/QueryHandler/ListJobsQueryHandler.php b/src/Domain/Shared/Application/QueryHandler/ListJobsQueryHandler.php index 3ad7a9b..28e4e76 100644 --- a/src/Domain/Shared/Application/QueryHandler/ListJobsQueryHandler.php +++ b/src/Domain/Shared/Application/QueryHandler/ListJobsQueryHandler.php @@ -28,7 +28,7 @@ readonly class ListJobsQueryHandler implements QueryHandlerInterface } $criteria = [ - 'status' => $query->status, + 'statuses' => $query->statuses, 'type' => $query->type, 'createdAfter' => $query->createdAfter, 'createdBefore' => $query->createdBefore, diff --git a/src/Domain/Shared/Domain/Contract/JobRepositoryInterface.php b/src/Domain/Shared/Domain/Contract/JobRepositoryInterface.php index cfb8755..029357e 100644 --- a/src/Domain/Shared/Domain/Contract/JobRepositoryInterface.php +++ b/src/Domain/Shared/Domain/Contract/JobRepositoryInterface.php @@ -18,6 +18,7 @@ interface JobRepositoryInterface /** * @param array{ * status?: ?JobStatus, + * statuses?: ?array, * type?: ?string, * createdAfter?: ?\DateTimeImmutable, * createdBefore?: ?\DateTimeImmutable, @@ -33,6 +34,7 @@ interface JobRepositoryInterface /** * @param array{ * status?: ?JobStatus, + * statuses?: ?array, * type?: ?string, * createdAfter?: ?\DateTimeImmutable, * createdBefore?: ?\DateTimeImmutable diff --git a/src/Domain/Shared/Infrastructure/ApiPlatform/Resource/GetJobListResource.php b/src/Domain/Shared/Infrastructure/ApiPlatform/Resource/GetJobListResource.php index 108f269..91eaafc 100644 --- a/src/Domain/Shared/Infrastructure/ApiPlatform/Resource/GetJobListResource.php +++ b/src/Domain/Shared/Infrastructure/ApiPlatform/Resource/GetJobListResource.php @@ -25,13 +25,26 @@ use Symfony\Component\Validator\Constraints as Assert; [ 'name' => 'status', 'in' => 'query', - 'description' => 'Filtrer par status', + 'description' => 'Filtrer par status (séparés par des virgules pour plusieurs statuts, ex: "pending,in_progress" ou sous forme de tableau avec "status[]=")', 'required' => false, 'schema' => [ - 'type' => 'string', - 'enum' => ['pending', 'in_progress', 'completed', 'failed'], - 'example' => 'pending' - ] + 'oneOf' => [ + [ + 'type' => 'string', + 'example' => 'pending,in_progress' + ], + [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + 'enum' => ['pending', 'in_progress', 'completed', 'failed', 'cancelled'] + ], + 'example' => ['pending', 'in_progress'] + ] + ] + ], + 'style' => 'form', + 'explode' => false ], [ 'name' => 'type', @@ -108,10 +121,7 @@ class GetJobListResource #[Assert\NotBlank] public readonly string $type, - #[ApiProperty( - description: 'Status du job', - openapiContext: ['enum' => ['pending', 'in_progress', 'completed', 'failed', 'cancelled']] - )] + #[ApiProperty(description: 'Status du job')] #[Assert\NotBlank] public readonly string $status, diff --git a/src/Domain/Shared/Infrastructure/ApiPlatform/State/Provider/GetJobListStateProvider.php b/src/Domain/Shared/Infrastructure/ApiPlatform/State/Provider/GetJobListStateProvider.php index d911612..f88c107 100644 --- a/src/Domain/Shared/Infrastructure/ApiPlatform/State/Provider/GetJobListStateProvider.php +++ b/src/Domain/Shared/Infrastructure/ApiPlatform/State/Provider/GetJobListStateProvider.php @@ -21,8 +21,34 @@ readonly class GetJobListStateProvider implements ProviderInterface { $filters = $context['filters'] ?? []; + $statuses = null; + if (isset($filters['status'])) { + if (is_array($filters['status'])) { + // Cas où status est passé en format tableau: status[]=pending&status[]=in_progress + $statusValues = $filters['status']; + } else { + // Cas où status est passé comme une chaîne séparée par des virgules: status=pending,in_progress + $statusValues = explode(',', $filters['status']); + } + + $statuses = []; + foreach ($statusValues as $statusValue) { + try { + $statuses[] = JobStatus::from($statusValue); + } catch (\ValueError $e) { + // Ignorer les valeurs invalides + continue; + } + } + + // Si aucun statut valide n'est trouvé, on met $statuses à null + if (empty($statuses)) { + $statuses = null; + } + } + $query = new ListJobsQuery( - status: isset($filters['status']) ? JobStatus::from($filters['status']) : null, + statuses: $statuses, type: $filters['type'] ?? null, createdAfter: isset($filters['createdAfter']) ? new \DateTimeImmutable($filters['createdAfter']) : null, createdBefore: isset($filters['createdBefore']) ? new \DateTimeImmutable($filters['createdBefore']) : null, diff --git a/src/Domain/Shared/Infrastructure/Persistence/Repository/DoctrineJobRepository.php b/src/Domain/Shared/Infrastructure/Persistence/Repository/DoctrineJobRepository.php index 6a25995..7afdb50 100644 --- a/src/Domain/Shared/Infrastructure/Persistence/Repository/DoctrineJobRepository.php +++ b/src/Domain/Shared/Infrastructure/Persistence/Repository/DoctrineJobRepository.php @@ -112,7 +112,15 @@ readonly class DoctrineJobRepository implements JobRepositoryInterface ->select('j') ->from(JobEntity::class, 'j'); - if (isset($criteria['status'])) { + if (isset($criteria['statuses']) && is_array($criteria['statuses']) && !empty($criteria['statuses'])) { + $expr = $qb->expr()->orX(); + foreach ($criteria['statuses'] as $key => $status) { + $paramName = 'status' . $key; + $expr->add($qb->expr()->eq('j.status', ':' . $paramName)); + $qb->setParameter($paramName, $status->value); + } + $qb->andWhere($expr); + } elseif (isset($criteria['status'])) { $qb->andWhere('j.status = :status') ->setParameter('status', $criteria['status']->value); } @@ -155,7 +163,15 @@ readonly class DoctrineJobRepository implements JobRepositoryInterface ->select('COUNT(j.id)') ->from(JobEntity::class, 'j'); - if (isset($criteria['status'])) { + if (isset($criteria['statuses']) && is_array($criteria['statuses']) && !empty($criteria['statuses'])) { + $expr = $qb->expr()->orX(); + foreach ($criteria['statuses'] as $key => $status) { + $paramName = 'status' . $key; + $expr->add($qb->expr()->eq('j.status', ':' . $paramName)); + $qb->setParameter($paramName, $status->value); + } + $qb->andWhere($expr); + } elseif (isset($criteria['status'])) { $qb->andWhere('j.status = :status') ->setParameter('status', $criteria['status']->value); }