up
This commit is contained in:
330
internal/document/document.go
Normal file
330
internal/document/document.go
Normal file
@@ -0,0 +1,330 @@
|
||||
package document
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// ── Model ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Document struct {
|
||||
ID string `json:"id"`
|
||||
PropertyID string `json:"property_id"`
|
||||
TransactionID string `json:"transaction_id,omitempty"`
|
||||
Filename string `json:"filename"`
|
||||
OriginalName string `json:"original_name"`
|
||||
FilePath string `json:"-"`
|
||||
MimeType string `json:"mime_type"`
|
||||
FiscalYear int `json:"fiscal_year"`
|
||||
Category string `json:"category"`
|
||||
UploadedBy string `json:"uploaded_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// Joint
|
||||
PropertyName string `json:"property_name,omitempty"`
|
||||
}
|
||||
|
||||
// ── Store ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Store struct{ db *sql.DB }
|
||||
|
||||
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||
|
||||
// Migrate ajoute la colonne category si elle n'existe pas encore.
|
||||
func (s *Store) Migrate() {
|
||||
s.db.Exec(`ALTER TABLE documents ADD COLUMN doc_month INTEGER NOT NULL DEFAULT 0`)
|
||||
s.db.Exec(`ALTER TABLE documents ADD COLUMN category TEXT NOT NULL DEFAULT ''`)
|
||||
}
|
||||
|
||||
func (s *Store) List(propertyID string, fiscalYear int, category string) ([]Document, error) {
|
||||
query := `
|
||||
SELECT d.id, d.property_id, COALESCE(d.transaction_id,''), d.filename, d.original_name,
|
||||
d.file_path, COALESCE(d.mime_type,''), COALESCE(d.fiscal_year,0),
|
||||
COALESCE(d.category,''),
|
||||
COALESCE(d.uploaded_by,''), d.created_at, p.name
|
||||
FROM documents d
|
||||
JOIN properties p ON p.id = d.property_id
|
||||
WHERE 1=1`
|
||||
args := []any{}
|
||||
if propertyID != "" {
|
||||
query += " AND d.property_id=?"
|
||||
args = append(args, propertyID)
|
||||
}
|
||||
if fiscalYear > 0 {
|
||||
query += " AND d.fiscal_year=?"
|
||||
args = append(args, fiscalYear)
|
||||
}
|
||||
if category != "" {
|
||||
query += " AND d.category=?"
|
||||
args = append(args, category)
|
||||
}
|
||||
query += " ORDER BY d.fiscal_year DESC, d.created_at DESC"
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var docs []Document
|
||||
for rows.Next() {
|
||||
var d Document
|
||||
if err := rows.Scan(&d.ID, &d.PropertyID, &d.TransactionID, &d.Filename, &d.OriginalName,
|
||||
&d.FilePath, &d.MimeType, &d.FiscalYear, &d.Category,
|
||||
&d.UploadedBy, &d.CreatedAt, &d.PropertyName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
docs = append(docs, d)
|
||||
}
|
||||
return docs, nil
|
||||
}
|
||||
|
||||
func (s *Store) Get(id string) (*Document, error) {
|
||||
var d Document
|
||||
err := s.db.QueryRow(`
|
||||
SELECT d.id, d.property_id, COALESCE(d.transaction_id,''), d.filename, d.original_name,
|
||||
d.file_path, COALESCE(d.mime_type,''), COALESCE(d.fiscal_year,0),
|
||||
COALESCE(d.category,''),
|
||||
COALESCE(d.uploaded_by,''), d.created_at, p.name
|
||||
FROM documents d
|
||||
JOIN properties p ON p.id = d.property_id
|
||||
WHERE d.id=?`, id,
|
||||
).Scan(&d.ID, &d.PropertyID, &d.TransactionID, &d.Filename, &d.OriginalName,
|
||||
&d.FilePath, &d.MimeType, &d.FiscalYear, &d.Category,
|
||||
&d.UploadedBy, &d.CreatedAt, &d.PropertyName)
|
||||
return &d, err
|
||||
}
|
||||
|
||||
func (s *Store) Create(d *Document) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO documents (id, property_id, transaction_id, filename, original_name, file_path, mime_type, fiscal_year, category, uploaded_by) VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||
d.ID, d.PropertyID, nullStr(d.TransactionID), d.Filename, d.OriginalName, d.FilePath,
|
||||
d.MimeType, nullInt(d.FiscalYear), nullStr(d.Category), nullStr(d.UploadedBy),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Delete(id string) (string, error) {
|
||||
var path string
|
||||
s.db.QueryRow(`SELECT file_path FROM documents WHERE id=?`, id).Scan(&path)
|
||||
_, err := s.db.Exec(`DELETE FROM documents WHERE id=?`, id)
|
||||
return path, err
|
||||
}
|
||||
|
||||
func (s *Store) GetByPropertyAndYear(propertyID string, year int) ([]Document, error) {
|
||||
return s.List(propertyID, year, "")
|
||||
}
|
||||
|
||||
// ── Handler ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type Handler struct {
|
||||
store *Store
|
||||
dataDir string
|
||||
}
|
||||
|
||||
func NewHandler(store *Store, dataDir string) *Handler {
|
||||
os.MkdirAll(dataDir, 0755)
|
||||
return &Handler{store: store, dataDir: dataDir}
|
||||
}
|
||||
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
var year int
|
||||
fmt.Sscanf(q.Get("fiscal_year"), "%d", &year)
|
||||
docs, err := h.store.List(q.Get("property_id"), year, q.Get("category"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if docs == nil {
|
||||
docs = []Document{}
|
||||
}
|
||||
respond(w, docs)
|
||||
}
|
||||
|
||||
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := h.store.Get(mux.Vars(r)["id"])
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
} else if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
respond(w, d)
|
||||
}
|
||||
|
||||
func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseMultipartForm(32 << 20)
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, "fichier requis", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
id := uuid.NewString()
|
||||
ext := filepath.Ext(header.Filename)
|
||||
filename := id + ext
|
||||
|
||||
propertyID := r.FormValue("property_id")
|
||||
year := r.FormValue("fiscal_year")
|
||||
dir := filepath.Join(h.dataDir, propertyID, year)
|
||||
os.MkdirAll(dir, 0755)
|
||||
|
||||
destPath := filepath.Join(dir, filename)
|
||||
dest, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
http.Error(w, "erreur création fichier", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer dest.Close()
|
||||
io.Copy(dest, file)
|
||||
|
||||
var fiscalYear int
|
||||
fmt.Sscanf(year, "%d", &fiscalYear)
|
||||
|
||||
d := &Document{
|
||||
ID: id,
|
||||
PropertyID: propertyID,
|
||||
TransactionID: r.FormValue("transaction_id"),
|
||||
Filename: filename,
|
||||
OriginalName: header.Filename,
|
||||
FilePath: destPath,
|
||||
MimeType: header.Header.Get("Content-Type"),
|
||||
FiscalYear: fiscalYear,
|
||||
Category: r.FormValue("category"),
|
||||
UploadedBy: r.FormValue("uploaded_by"),
|
||||
}
|
||||
|
||||
if err := h.store.Create(d); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
respond(w, d)
|
||||
}
|
||||
|
||||
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := h.store.Get(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, d.OriginalName))
|
||||
if d.MimeType != "" {
|
||||
w.Header().Set("Content-Type", d.MimeType)
|
||||
}
|
||||
http.ServeFile(w, r, d.FilePath)
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
path, err := h.store.Delete(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if path != "" {
|
||||
os.Remove(path)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Export génère un ZIP avec la structure : année/catégorie/fichier
|
||||
func (h *Handler) Export(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
propertyID := q.Get("property_id")
|
||||
yearStr := q.Get("year")
|
||||
var year int
|
||||
fmt.Sscanf(yearStr, "%d", &year)
|
||||
if year == 0 {
|
||||
year = time.Now().Year()
|
||||
yearStr = fmt.Sprintf("%d", year)
|
||||
}
|
||||
|
||||
docs, err := h.store.List(propertyID, year, "")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if len(docs) == 0 {
|
||||
http.Error(w, "aucun document pour ces critères", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
propLabel := "tous"
|
||||
if propertyID != "" {
|
||||
if docs[0].PropertyName != "" {
|
||||
propLabel = sanitizeName(docs[0].PropertyName)
|
||||
} else {
|
||||
propLabel = propertyID[:8]
|
||||
}
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("documents_%s_%s.zip", propLabel, yearStr)
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
|
||||
zw := zip.NewWriter(w)
|
||||
defer zw.Close()
|
||||
|
||||
for _, d := range docs {
|
||||
catDir := "Sans catégorie"
|
||||
if strings.TrimSpace(d.Category) != "" {
|
||||
catDir = sanitizeName(d.Category)
|
||||
}
|
||||
|
||||
zipPath := filepath.Join(yearStr, catDir, d.OriginalName)
|
||||
zipPath = strings.ReplaceAll(zipPath, "\\", "/")
|
||||
|
||||
f, err := os.Open(d.FilePath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
entry, err := zw.Create(zipPath)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
continue
|
||||
}
|
||||
io.Copy(entry, f)
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeName(s string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"/", "-", "\\", "-", ":", "-", "*", "-",
|
||||
"?", "-", "\"", "-", "<", "-", ">", "-", "|", "-",
|
||||
)
|
||||
return strings.TrimSpace(replacer.Replace(s))
|
||||
}
|
||||
|
||||
func respond(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func nullStr(s string) any {
|
||||
if s == "" || strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func nullInt(i int) any {
|
||||
if i == 0 {
|
||||
return nil
|
||||
}
|
||||
return i
|
||||
}
|
||||
Reference in New Issue
Block a user