Skip to content

Commit 313df1f

Browse files
committed
✨ Enhance Article and Discussion Management
- Update ArticlesController to allow users to view and manage their own articles, including new methods for listing, editing, and updating articles. - Refactor ArticleStatsService to support fetching statistics based on user ID for personalized dashboard insights. - Improve DiscussionsController to ensure proper deletion of discussions and associated messages/bans, with admin role verification. - Add new routes for article management and discussion deletion, enforcing admin middleware for sensitive actions. - Update .gitignore to include new directories for better project organization.
1 parent 5316334 commit 313df1f

18 files changed

Lines changed: 1944 additions & 33 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,5 @@ yarn-error.log
2323

2424
# Platform specific
2525
.DS_Store
26-
memory-bank/
26+
memory-bank/
27+
.claude/

app/controllers/articles_controller.ts

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ArticleStatus } from '#enums/article_status'
2+
import { Role } from '#enums/role'
23
import Article from '#models/article'
34
import ArticleStatsService from '#services/article_stats_service'
45
import { articleValidator } from '#validators/article_validator'
@@ -49,8 +50,9 @@ export default class ArticlesController {
4950
return inertia.render('articles/[slug]', { article })
5051
}
5152

52-
async dashboard({ inertia }: HttpContext) {
53-
const stats = await ArticleStatsService.getStats()
53+
async dashboard({ inertia, auth }: HttpContext) {
54+
// Chaque utilisateur ne voit que ses propres statistiques
55+
const stats = await ArticleStatsService.getStats(auth.user!.id)
5456
return inertia.render('dashboard/index', {
5557
publishedArticles: stats.published,
5658
draftArticles: stats.drafts,
@@ -59,4 +61,127 @@ export default class ArticlesController {
5961
questions: 0,
6062
})
6163
}
64+
65+
async articles({ inertia, request, auth }: HttpContext) {
66+
const page = request.input('page', 1)
67+
const status = request.input('status', null)
68+
69+
const query = Article.query()
70+
.preload('author')
71+
.where('author_id', auth.user!.id)
72+
.orderBy('created_at', 'desc')
73+
74+
if (status && Object.values(ArticleStatus).includes(status as ArticleStatus)) {
75+
query.where('status', status as ArticleStatus)
76+
}
77+
78+
const articles = await query.paginate(page, 10)
79+
80+
return inertia.render('dashboard/articles', {
81+
articles: articles.toJSON(),
82+
currentStatus: status,
83+
})
84+
}
85+
86+
async edit({ inertia, params, auth, response }: HttpContext) {
87+
const article = await Article.query()
88+
.where('slug', params.slug)
89+
.where('author_id', auth.user!.id)
90+
.firstOrFail()
91+
92+
return inertia.render('dashboard/articles/edit', { article })
93+
}
94+
95+
async update({ request, params, auth, response }: HttpContext) {
96+
const article = await Article.query()
97+
.where('slug', params.slug)
98+
.where('author_id', auth.user!.id)
99+
.firstOrFail()
100+
101+
const data = await articleValidator.validate(request.all())
102+
103+
const payload: Partial<Article> = { ...data }
104+
105+
// Si on passe de draft/waiting à published, mettre à jour publishedAt
106+
if (
107+
data.status === ArticleStatus.PUBLISHED &&
108+
article.status !== ArticleStatus.PUBLISHED
109+
) {
110+
payload.publishedAt = DateTime.now()
111+
}
112+
113+
// Si le titre change, générer un nouveau slug
114+
if (data.title !== article.title) {
115+
payload.slug = Article.generateSlug(data.title)
116+
}
117+
118+
await article.merge(payload).save()
119+
120+
return response.redirect().toRoute('articles.show', { slug: article.slug })
121+
}
122+
123+
// ADMIN METHODS
124+
125+
/**
126+
* Liste des articles pour l'admin
127+
* - Tous les articles publiés (de tous les auteurs)
128+
* - Ses propres brouillons seulement
129+
*/
130+
async adminArticles({ inertia, request, auth }: HttpContext) {
131+
const page = request.input('page', 1)
132+
const status = request.input('status', null)
133+
134+
let query = Article.query().preload('author').orderBy('created_at', 'desc')
135+
136+
if (status && Object.values(ArticleStatus).includes(status as ArticleStatus)) {
137+
if (status === ArticleStatus.PUBLISHED) {
138+
// Admin voit tous les articles publiés
139+
query.where('status', ArticleStatus.PUBLISHED)
140+
} else {
141+
// Admin ne voit que ses propres brouillons/en attente
142+
query.where('status', status as ArticleStatus).where('author_id', auth.user!.id)
143+
}
144+
} else {
145+
// Par défaut : tous les publiés + ses brouillons
146+
query.where((builder) => {
147+
builder
148+
.where('status', ArticleStatus.PUBLISHED)
149+
.orWhere((subBuilder) => {
150+
subBuilder.where('author_id', auth.user!.id).whereIn('status', [
151+
ArticleStatus.DRAFT,
152+
ArticleStatus.WAITING_APPROVAL,
153+
])
154+
})
155+
})
156+
}
157+
158+
const articles = await query.paginate(page, 10)
159+
160+
return inertia.render('admin/articles', {
161+
articles: articles.toJSON(),
162+
currentStatus: status,
163+
})
164+
}
165+
166+
/**
167+
* Dépublier un article (admin seulement)
168+
* Passe le statut de PUBLISHED à DRAFT
169+
*/
170+
async unpublish({ params, response, session, auth }: HttpContext) {
171+
const article = await Article.query().where('slug', params.slug).firstOrFail()
172+
173+
// Vérifier que l'article est publié
174+
if (article.status !== ArticleStatus.PUBLISHED) {
175+
session.flash('error', 'Cet article n\'est pas publié')
176+
return response.redirect().back()
177+
}
178+
179+
// Dépublier l'article
180+
article.status = ArticleStatus.DRAFT
181+
article.publishedAt = null
182+
await article.save()
183+
184+
session.flash('success', 'Article dépublié avec succès')
185+
return response.redirect().back()
186+
}
62187
}

app/controllers/discussions_controller.ts

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,34 +56,50 @@ export default class DiscussionsController {
5656
return response.redirect().toRoute('discussions.show', { id: discussion.id })
5757
}
5858

59-
async destroy({ params, auth, response }: HttpContext) {
60-
// Suppression par admin
61-
// (Vérifier le rôle admin dans la logique réelle)
62-
const discussion = await Discussion.findOrFail(params.id)
59+
async destroy({ params, response, session }: HttpContext) {
60+
// Suppression par admin uniquement (vérifiée par middleware)
61+
const discussion = await Discussion.query()
62+
.where('id', params.id)
63+
.preload('messages')
64+
.preload('bans')
65+
.firstOrFail()
66+
67+
// Supprimer tous les messages associés
68+
await Message.query().where('discussion_id', discussion.id).delete()
69+
70+
// Supprimer tous les bans associés
71+
await BannedUser.query().where('discussion_id', discussion.id).delete()
72+
73+
// Supprimer la discussion
6374
await discussion.delete()
75+
76+
session.flash('success', 'Discussion supprimée avec succès')
6477
return response.redirect().toRoute('discussions.index')
6578
}
6679

6780
async ban({ params, request, auth, response }: HttpContext) {
68-
if (auth.user?.role !== 'ADMIN') {
69-
return response.unauthorized('Réservé admin')
70-
}
81+
// Bannissement par admin uniquement (vérifiée par middleware)
7182
const { userId, reason } = request.only(['userId', 'reason'])
7283
const discussionId = params.id
84+
7385
// Vérifier si déjà banni
7486
const existing = await BannedUser.query()
7587
.where('user_id', userId)
7688
.where('discussion_id', discussionId)
7789
.first()
90+
7891
if (existing) {
79-
return response.badRequest('Déjà banni')
92+
return response.badRequest({ error: 'Utilisateur déjà banni de cette discussion' })
8093
}
94+
95+
// Créer le ban
8196
await BannedUser.create({
8297
userId,
8398
discussionId,
84-
adminId: auth.user.id,
99+
adminId: auth.user!.id,
85100
reason: reason || null,
86101
})
87-
return response.ok({ success: true })
102+
103+
return response.ok({ success: true, message: 'Utilisateur banni avec succès' })
88104
}
89105
}

app/middleware/admin_middleware.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { HttpContext } from '@adonisjs/core/http'
2+
import type { NextFn } from '@adonisjs/core/types/http'
3+
import { Role } from '#enums/role'
4+
5+
/**
6+
* Admin middleware is used to restrict access to admin-only routes
7+
* The user must be authenticated and have an ADMIN role
8+
*/
9+
export default class AdminMiddleware {
10+
/**
11+
* The URL to redirect to when authorization fails
12+
*/
13+
redirectTo = '/discussions'
14+
15+
async handle(ctx: HttpContext, next: NextFn) {
16+
// Ensure user is authenticated
17+
await ctx.auth.check()
18+
19+
// Check if user has admin role
20+
if (!ctx.auth.user || ctx.auth.user.role !== Role.ADMIN) {
21+
return ctx.response.abort('Accès réservé aux administrateurs', 403)
22+
}
23+
24+
return next()
25+
}
26+
}

app/services/article_stats_service.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,22 @@ import { ArticleStatus } from '#enums/article_status'
22
import Article from '#models/article'
33

44
export default class ArticleStatsService {
5-
static async getStats() {
6-
const publishedResult = await Article.query()
7-
.where('status', ArticleStatus.PUBLISHED)
8-
.count('* as total')
9-
const draftResult = await Article.query()
10-
.where('status', ArticleStatus.DRAFT)
11-
.count('* as total')
12-
const waitingResult = await Article.query()
13-
.where('status', ArticleStatus.WAITING_APPROVAL)
14-
.count('* as total')
5+
static async getStats(userId?: number) {
6+
const publishedQuery = Article.query().where('status', ArticleStatus.PUBLISHED)
7+
const draftQuery = Article.query().where('status', ArticleStatus.DRAFT)
8+
const waitingQuery = Article.query().where('status', ArticleStatus.WAITING_APPROVAL)
9+
10+
// Si userId est fourni, filtrer par l'auteur
11+
if (userId) {
12+
publishedQuery.where('author_id', userId)
13+
draftQuery.where('author_id', userId)
14+
waitingQuery.where('author_id', userId)
15+
}
16+
17+
const publishedResult = await publishedQuery.count('* as total')
18+
const draftResult = await draftQuery.count('* as total')
19+
const waitingResult = await waitingQuery.count('* as total')
20+
1521
return {
1622
published: Number(publishedResult[0].$extras.total),
1723
drafts: Number(draftResult[0].$extras.total),

app/services/minio_service.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,25 @@ export default class MinioService {
2525
}
2626

2727
static async getPresignedUrl(fileName: string, mimeType: string) {
28+
// S'assurer que le bucket existe
29+
const exists = await minioClient.bucketExists(BUCKET)
30+
if (!exists) {
31+
await minioClient.makeBucket(BUCKET)
32+
}
33+
2834
const ext = fileName.split('.').pop()
2935
const key = `${uuidv4()}.${ext}`
3036
const url = await minioClient.presignedPutObject(BUCKET, key, 60 * 5)
31-
return { url, key }
37+
return { url, key, mimeType }
3238
}
3339

3440
static async getPresignedViewUrl(key: string, expirySeconds = 300): Promise<string> {
41+
// S'assurer que le bucket existe
42+
const exists = await minioClient.bucketExists(BUCKET)
43+
if (!exists) {
44+
await minioClient.makeBucket(BUCKET)
45+
}
46+
3547
return minioClient.presignedGetObject(BUCKET, key, expirySeconds)
3648
}
3749
}

config/database.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,18 @@ const dbConfig = defineConfig({
99
connection: {
1010
host: env.get('DB_HOST'),
1111
port: env.get('DB_PORT'),
12-
user: env.get('DB_USER'),
12+
user: env.get('DB_USERNAME'),
1313
password: env.get('DB_PASSWORD'),
1414
database: env.get('DB_DATABASE'),
1515
},
16+
pool: {
17+
min: 2, // Minimum de connexions actives
18+
max: 10, // Maximum de connexions simultanées
19+
acquireTimeoutMillis: 30000, // 30 secondes pour acquérir une connexion
20+
createTimeoutMillis: 30000, // 30 secondes pour créer une connexion
21+
idleTimeoutMillis: 30000, // 30 secondes avant de fermer une connexion inactive
22+
createRetryIntervalMillis: 200, // 200ms entre les tentatives de création
23+
},
1624
migrations: {
1725
naturalSort: true,
1826
paths: ['database/migrations'],
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { BaseSchema } from '@adonisjs/lucid/schema'
2+
3+
export default class extends BaseSchema {
4+
protected tableName = 'articles'
5+
6+
async up() {
7+
this.schema.alterTable(this.tableName, (table) => {
8+
table.text('cover_image').nullable().alter()
9+
})
10+
}
11+
12+
async down() {
13+
this.schema.alterTable(this.tableName, (table) => {
14+
table.string('cover_image').nullable().alter()
15+
})
16+
}
17+
}

inertia/components/articles/article-card.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export default function ArticleCard({ article }: ArticleCardProps) {
1818
const [imgError, setImgError] = useState(false)
1919
const showFallback = imgError || !article.coverImage
2020

21+
console.log('article.coverImage', article.coverImage)
22+
2123
return (
2224
<div className="bg-white rounded-xl shadow flex flex-col h-full cursor-pointer overflow-hidden border-2 border-gray-200 transition-all duration-300 hover:border-indigo-600">
2325
<div

0 commit comments

Comments
 (0)