- Dockerfile multi-étapes (node:22-alpine, pnpm, output standalone), .dockerignore - docker-compose : service web (hôte 4000) + mysql + mongo avec volumes nommés - Env de connexion selon DB_IMPL (MYSQL_*, MONGODB_URI) ; mongo ?authSource=admin - Seed MySQL via 02-seed.sh (--default-character-set=utf8mb4) pour un UTF-8 propre - fix(api): parseTags gère les colonnes JSON déjà parsées par mysql2 - TODO : phase 5 et DoD cochées
151 lines
4.2 KiB
TypeScript
151 lines
4.2 KiB
TypeScript
import { createPool, type Pool, type ResultSetHeader, type RowDataPacket } from 'mysql2/promise'
|
|
import type { Card, CardInput } from '../domain/card'
|
|
import type { CardRepository } from '../domain/card-repository'
|
|
|
|
interface CardRow extends RowDataPacket {
|
|
id: string
|
|
question: string
|
|
response: string
|
|
theme: string
|
|
tags: string | null
|
|
createdAt: Date
|
|
updatedAt: Date
|
|
}
|
|
|
|
const FIELDS = ['id', 'question', 'response', 'theme', 'tags', 'createdAt', 'updatedAt'] as const
|
|
|
|
export class MysqlCardRepository implements CardRepository {
|
|
private readonly pool: Pool
|
|
|
|
constructor(pool?: Pool) {
|
|
this.pool =
|
|
pool ??
|
|
createPool({
|
|
host: process.env.MYSQL_HOST ?? 'localhost',
|
|
port: Number(process.env.MYSQL_PORT ?? 13306),
|
|
user: process.env.MYSQL_USER ?? 'flashcards',
|
|
password: process.env.MYSQL_PASSWORD ?? 'flashcards',
|
|
database: process.env.MYSQL_DATABASE ?? 'flashcards',
|
|
})
|
|
}
|
|
|
|
private parseTags(value: string | unknown): string[] {
|
|
if (Array.isArray(value)) {
|
|
return value as string[]
|
|
}
|
|
if (typeof value === 'string') {
|
|
try {
|
|
const parsed = JSON.parse(value)
|
|
return Array.isArray(parsed) ? parsed : []
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
return []
|
|
}
|
|
|
|
private mapRow(row: CardRow): Card {
|
|
return {
|
|
id: row.id,
|
|
question: row.question,
|
|
response: row.response,
|
|
theme: row.theme,
|
|
tags: this.parseTags(row.tags),
|
|
createdAt: new Date(row.createdAt),
|
|
updatedAt: new Date(row.updatedAt),
|
|
}
|
|
}
|
|
|
|
async findAll(): Promise<Card[]> {
|
|
const [rows] = await this.pool.query<CardRow[]>(`SELECT ${FIELDS.join(', ')} FROM cards`)
|
|
return rows.map((row) => this.mapRow(row))
|
|
}
|
|
|
|
async findById(id: string): Promise<Card | null> {
|
|
const [rows] = await this.pool.query<CardRow[]>(
|
|
`SELECT ${FIELDS.join(', ')} FROM cards WHERE id = ?`,
|
|
[id],
|
|
)
|
|
return rows[0] ? this.mapRow(rows[0]) : null
|
|
}
|
|
|
|
async create(input: CardInput): Promise<Card> {
|
|
const id = crypto.randomUUID()
|
|
const now = new Date()
|
|
await this.pool.query(`INSERT INTO cards (${FIELDS.join(', ')}) VALUES (?, ?, ?, ?, ?, ?, ?)`, [
|
|
id,
|
|
input.question,
|
|
input.response,
|
|
input.theme,
|
|
JSON.stringify(input.tags ?? []),
|
|
now,
|
|
now,
|
|
])
|
|
return {
|
|
id,
|
|
question: input.question,
|
|
response: input.response,
|
|
theme: input.theme,
|
|
tags: input.tags ?? [],
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
}
|
|
|
|
async update(id: string, input: Partial<CardInput>): Promise<Card | null> {
|
|
const sets: string[] = []
|
|
const values: unknown[] = []
|
|
if (input.question !== undefined) {
|
|
sets.push('question = ?')
|
|
values.push(input.question)
|
|
}
|
|
if (input.response !== undefined) {
|
|
sets.push('response = ?')
|
|
values.push(input.response)
|
|
}
|
|
if (input.theme !== undefined) {
|
|
sets.push('theme = ?')
|
|
values.push(input.theme)
|
|
}
|
|
if (input.tags !== undefined) {
|
|
sets.push('tags = ?')
|
|
values.push(JSON.stringify(input.tags))
|
|
}
|
|
if (sets.length === 0) {
|
|
return this.findById(id)
|
|
}
|
|
sets.push('updatedAt = ?')
|
|
values.push(new Date())
|
|
values.push(id)
|
|
await this.pool.query(`UPDATE cards SET ${sets.join(', ')} WHERE id = ?`, values)
|
|
return this.findById(id)
|
|
}
|
|
|
|
async delete(id: string): Promise<boolean> {
|
|
const [result] = await this.pool.query<ResultSetHeader>('DELETE FROM cards WHERE id = ?', [id])
|
|
return result.affectedRows > 0
|
|
}
|
|
|
|
async findRandom(): Promise<Card | null> {
|
|
const [rows] = await this.pool.query<CardRow[]>(
|
|
`SELECT ${FIELDS.join(', ')} FROM cards ORDER BY RAND() LIMIT 1`,
|
|
)
|
|
return rows[0] ? this.mapRow(rows[0]) : null
|
|
}
|
|
|
|
async findRandomByTheme(theme: string): Promise<Card | null> {
|
|
const [rows] = await this.pool.query<CardRow[]>(
|
|
`SELECT ${FIELDS.join(', ')} FROM cards WHERE theme = ? ORDER BY RAND() LIMIT 1`,
|
|
[theme],
|
|
)
|
|
return rows[0] ? this.mapRow(rows[0]) : null
|
|
}
|
|
|
|
async findThemes(): Promise<string[]> {
|
|
const [rows] = await this.pool.query<RowDataPacket[]>(
|
|
'SELECT DISTINCT theme FROM cards ORDER BY theme',
|
|
)
|
|
return rows.map((row) => row.theme as string)
|
|
}
|
|
}
|