# Piano: Integrazione MinIO come storage esterno

## Contesto e obiettivo

Attualmente i file caricati dagli utenti (CV e documenti anagrafici) vengono salvati sul filesystem locale del server:
- CV → `backend/uploads/cv/{timestamp}-{rand}.{ext}`
- Documenti anagrafica → `backend/uploads/documents/{token_o_id}/{timestamp}-{rand}.{ext}`

Il DB conserva solo il nome/percorso relativo del file (`cv_path`, `application_documents.file_path`).

L'obiettivo è spostare tutto su **MinIO** (`storage.gixflow.cloud:9100`) così:
- i file non occupano disco sul server applicativo
- un bucket dedicato (`evoluzioneazienda-admin`) funge da archivio sicuro
- il backend continua a servire i file (stream autenticato), senza URL pubblici diretti

---

## Convenzione chiavi oggetto MinIO

| Tipo file | Chiave MinIO |
|-----------|-------------|
| CV | `cv/{filename}` es. `cv/1778145685134-abc123.pdf` |
| Documento anagrafica | `documents/{application_id}/{filename}` es. `documents/43/1779279598479-abc.jpg` |

Il valore salvato nel DB **rimane invariato** rispetto a oggi (solo il nome file per i CV, solo `{token}/{filename}` per i documenti), tranne che la colonna `file_path` su `application_documents` dovrà passare a `{application_id}/{filename}` — già fatto in `submitOnboardingForm` che usa `token`, ma a regime andrà allineato alla convenzione `application_id`.

---

## File da creare

### `backend/src/services/minioClient.js`

Modulo ESM che esporta `{ minioClient, bucketName }`.

```js
import Minio from 'minio'
import 'dotenv/config'

if (!process.env.MINIO_BUCKET_NAME) throw new Error('MINIO_BUCKET_NAME è obbligatorio nel .env')

const bucketName = process.env.MINIO_BUCKET_NAME

const minioClient = new Minio.Client({
  endPoint:  process.env.MINIO_ENDPOINT,
  port:      parseInt(process.env.MINIO_PORT),
  useSSL:    process.env.MINIO_USE_SSL === 'true',
  accessKey: process.env.MINIO_ACCESS_KEY,
  secretKey: process.env.MINIO_SECRET_KEY,
  region:    process.env.MINIO_REGION || 'us-east-1',
})

// Crea il bucket se non esiste
minioClient.bucketExists(bucketName, (err, exists) => {
  if (err) { console.error('[MinIO] Errore bucketExists:', err); return }
  if (!exists) {
    minioClient.makeBucket(bucketName, 'us-east-1', (err) => {
      if (err) console.error('[MinIO] Errore makeBucket:', err)
      else console.log(`[MinIO] Bucket "${bucketName}" creato.`)
    })
  }
})

export { minioClient, bucketName }
```

---

## File da modificare

### 1. `backend/.env`

Aggiungere le variabili MinIO (vedere `appunti_minio.txt` per i valori reali):

```
# MINIO
MINIO_ENDPOINT=storage.gixflow.cloud
MINIO_PORT=9100
MINIO_USE_SSL=true
MINIO_ACCESS_KEY=gixflowadmin
MINIO_SECRET_KEY=<secret>
MINIO_REGION=us-east-1
MINIO_BUCKET_NAME=evoluzioneazienda-admin
```

---

### 2. `backend/src/middleware/upload.js`

Sostituire entrambe le `diskStorage` con **`memoryStorage`** — i file arrivano come `req.file.buffer` invece di essere scritti su disco.

```js
import multer from 'multer'
import path   from 'path'
import crypto from 'crypto'

// Nomi casuali rimangono — usati poi come chiave MinIO
function buildFilename(file) {
  const ext  = path.extname(file.originalname).toLowerCase()
  const rand = crypto.randomBytes(16).toString('hex')
  return `${Date.now()}-${rand}${ext}`
}

const ALLOWED_MIMES     = ['application/pdf','application/msword','application/vnd.openxmlformats-officedocument.wordprocessingml.document']
const DOC_ALLOWED_MIMES = ['application/pdf','image/jpeg','image/png','image/webp']

const memStorage = multer.memoryStorage()

function fileFilter(allowed) {
  return (_req, file, cb) => {
    if (allowed.includes(file.mimetype)) cb(null, true)
    else cb(new Error('Formato non consentito.'))
  }
}

export const uploadCV = multer({
  storage: memStorage,
  limits: { fileSize: 2 * 1024 * 1024 },
  fileFilter: fileFilter(ALLOWED_MIMES),
}).single('cv')

export const uploadDocuments = multer({
  storage: memStorage,
  limits: { fileSize: 10 * 1024 * 1024 },
  fileFilter: fileFilter(DOC_ALLOWED_MIMES),
}).fields([
  { name: 'id_front',    maxCount: 1 },
  { name: 'id_back',     maxCount: 1 },
  { name: 'fiscal_code', maxCount: 1 },
])

// Helper riusabile: costruisce il filename casuale e lo attacca al file
// (sostituisce ciò che diskStorage faceva in "filename")
export function assignFilename(file) {
  file.storedFilename = buildFilename(file)
  return file.storedFilename
}
```

> **Nota**: non serve più l'import di `fs` e `fileURLToPath` in questo modulo.

---

### 3. `backend/src/controllers/applyController.js`

Dopo il multer upload (`req.file.buffer` + `req.file.mimetype`), caricare su MinIO e usare la chiave come `cv_path`.

**Cambiamento chiave** — dove ora si fa:
```js
const cvPath = req.file ? req.file.filename : null
```
Diventa:
```js
let cvPath = null
if (req.file) {
  const filename  = assignFilename(req.file)          // genera timestamp-rand.ext
  const objectKey = `cv/${filename}`
  await minioClient.putObject(bucketName, objectKey, req.file.buffer, req.file.size, { 'Content-Type': req.file.mimetype })
  cvPath = filename   // DB conserva solo il filename (compatibilità)
}
```

---

### 4. `backend/src/controllers/adminController.js`

Quattro punti da aggiornare:

#### 4a. `serveCv()` — stream da MinIO invece di `res.download(localPath)`

```js
export async function serveCv(req, res) {
  const id = parseInt(req.params.id, 10)
  if (isNaN(id)) return res.status(400).json({ error: 'ID non valido.' })
  try {
    const [[row]] = await db.query('SELECT cv_path FROM applications WHERE id = ?', [id])
    if (!row?.cv_path) return res.status(404).json({ error: 'Nessun CV allegato.' })

    const objectKey = `cv/${path.basename(row.cv_path)}`
    const stream    = await minioClient.getObject(bucketName, objectKey)
    res.setHeader('Content-Disposition', `attachment; filename="${path.basename(row.cv_path)}"`)
    stream.pipe(res)
  } catch (err) {
    if (err.code === 'NoSuchKey') return res.status(404).json({ error: 'File non trovato.' })
    console.error('[EvoAz Admin] serveCv:', err.message)
    res.status(500).json({ error: 'Errore interno del server.' })
  }
}
```

#### 4b. `deleteApplication()` — rimuovere oggetto da MinIO

```js
if (row?.cv_path) {
  await minioClient.removeObject(bucketName, `cv/${path.basename(row.cv_path)}`).catch(() => {})
}
```

#### 4c. `updateApplicationAnagrafica()` — sostituire vecchio CV su MinIO

```js
if (req.file) {
  const [[old]] = await db.query('SELECT cv_path FROM applications WHERE id = ?', [id])
  if (old?.cv_path) {
    await minioClient.removeObject(bucketName, `cv/${path.basename(old.cv_path)}`).catch(() => {})
  }
  const filename  = assignFilename(req.file)
  await minioClient.putObject(bucketName, `cv/${filename}`, req.file.buffer, req.file.size, { 'Content-Type': req.file.mimetype })
  newCvPath = filename
  cvUpdate  = ', cv_path=?'
}
```

#### 4d. `createApplicationAdmin()` — upload buffer su MinIO

Identico al punto 3 (`applyController.js`).

---

### 5. `backend/src/controllers/onboardingController.js`

#### 5a. `submitOnboardingForm()` — upload documenti su MinIO

```js
if (req.files && typeof req.files === 'object') {
  for (const [fieldname, fileArr] of Object.entries(req.files)) {
    for (const file of fileArr) {
      const filename  = assignFilename(file)
      const objectKey = `documents/${tok.application_id}/${filename}`
      await minioClient.putObject(bucketName, objectKey, file.buffer, file.size, { 'Content-Type': file.mimetype })

      // Salva in DB la chiave MinIO come file_path (solo {appId}/{filename})
      const dbPath = `${tok.application_id}/${filename}`
      await db.query(
        'INSERT INTO application_documents (application_id, doc_type, file_path, original_name) VALUES (?, ?, ?, ?)',
        [tok.application_id, fieldname, dbPath, file.originalname]
      )
    }
  }
}
```

> **Importante**: la convenzione `file_path` cambia da `{token}/{filename}` a `{application_id}/{filename}`. Occorre una migration per i record già presenti (vedi sezione Migrazione).

#### 5b. `serveDocument()` — stream da MinIO

```js
export async function serveDocument(req, res) {
  const { id, docId } = req.params
  try {
    const [rows] = await db.query(
      'SELECT file_path, original_name FROM application_documents WHERE id = ? AND application_id = ?',
      [docId, id]
    )
    const doc = rows[0]
    if (!doc) return res.status(404).json({ error: 'Documento non trovato.' })

    const objectKey = `documents/${doc.file_path}`
    const stream    = await minioClient.getObject(bucketName, objectKey)
    res.setHeader('Content-Disposition', `attachment; filename="${doc.original_name}"`)
    stream.pipe(res)
  } catch (err) {
    if (err.code === 'NoSuchKey') return res.status(404).json({ error: 'File non più disponibile.' })
    console.error('[Onboarding] serveDocument:', err.message)
    res.status(500).json({ error: 'Errore interno.' })
  }
}
```

---

## Installazione dipendenza

```bash
cd backend
npm install minio
```

---

## Script di migrazione file esistenti

Da eseguire **una volta sola** prima di mettere in produzione il nuovo codice.
Crea `backend/scripts/migrate-to-minio.js`:

```js
// Esegui con: node --env-file=.env scripts/migrate-to-minio.js
import { minioClient, bucketName } from '../src/services/minioClient.js'
import fs   from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const CV_DIR    = path.join(__dirname, '../uploads/cv')
const DOCS_DIR  = path.join(__dirname, '../uploads/documents')

async function uploadDir(localDir, prefix) {
  if (!fs.existsSync(localDir)) return
  for (const entry of fs.readdirSync(localDir, { withFileTypes: true })) {
    if (entry.isFile()) {
      const localPath = path.join(localDir, entry.name)
      const objectKey = `${prefix}/${entry.name}`
      await minioClient.fPutObject(bucketName, objectKey, localPath)
      console.log('Caricato:', objectKey)
    } else if (entry.isDirectory()) {
      await uploadDir(path.join(localDir, entry.name), `${prefix}/${entry.name}`)
    }
  }
}

await uploadDir(CV_DIR,   'cv')
await uploadDir(DOCS_DIR, 'documents')
console.log('Migrazione completata.')
```

> Dopo la migrazione, i file locali in `uploads/` possono essere rimossi.
> Aggiornare anche i record `application_documents` che usano `{token}/{filename}` convertendoli in `{application_id}/{filename}`.

---

## Ordine di deployment

1. `npm install minio` nel backend
2. Aggiornare `.env` con le variabili MinIO
3. Eseguire `node --env-file=.env scripts/migrate-to-minio.js` per caricare i file esistenti
4. Deploy del nuovo codice (upload.js, minioClient.js, controller aggiornati)
5. Testare: upload CV, upload documenti onboarding, download CV, download documento
6. Se tutto OK, rimuovere la cartella `uploads/` locale (opzionale, tenerla come fallback temporaneo)

---

## Impatto sul frontend

Nessuno. Le route REST (`/api/admin/applications/:id/cv`, `/api/admin/applications/:id/documents/:docId`) restano invariate — il backend fa lo streaming da MinIO in modo trasparente.

---

## Rischi e note

| Rischio | Mitigazione |
|---------|-------------|
| MinIO offline → upload fallisce | Il backend ritorna 500; l'utente riprova. I file su disco locale non esistono più come fallback dopo il deploy. |
| Latenza upload MinIO su candidature pubbliche | Accettabile: file max 2 MB su rete locale/cloud. |
| `application_documents.file_path` misti (vecchi `token/file`, nuovi `appId/file`) | SQL migration per normalizzare tutti i record esistenti alla convenzione `appId/file`. |
| Bucket pubblico vs privato | Il bucket deve restare **privato** — il backend fa da proxy autenticato. Non aggiungere policy pubbliche. |
