fix: resolver DNS nginx pour éviter cache IP stale après restart backend
This commit is contained in:
@@ -1,28 +0,0 @@
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
function require_env(key: string): string {
|
||||
const val = process.env[key];
|
||||
if (!val) throw new Error(`Variable d'environnement manquante : ${key}`);
|
||||
return val;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
port: parseInt(process.env.PORT || '3001'),
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
|
||||
// Base de données
|
||||
databaseUrl: process.env.DATABASE_URL || 'postgresql://notesfrais:notesfrais@localhost:5432/notesfrais',
|
||||
|
||||
// JWT
|
||||
jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-in-prod',
|
||||
jwtExpiresIn: (process.env.JWT_EXPIRES_IN || '15m') as string,
|
||||
refreshTokenExpiresDays: parseInt(process.env.REFRESH_TOKEN_EXPIRES_DAYS || '30'),
|
||||
|
||||
// Chiffrement AES-256 (mots de passe SMTP, secrets Graph)
|
||||
appSecret: process.env.APP_SECRET || 'dev-app-secret-change-in-prod',
|
||||
|
||||
// Stockage fichiers
|
||||
uploadsDir: process.env.UPLOADS_DIR || './uploads',
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* Chiffrement AES-256-GCM symétrique.
|
||||
* Utilisé pour stocker les mots de passe SMTP et secrets Microsoft Graph en BDD.
|
||||
* La clé est dérivée de APP_SECRET (variable d'environnement).
|
||||
*/
|
||||
import crypto from 'crypto';
|
||||
import { config } from './config';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 16;
|
||||
const TAG_LENGTH = 16;
|
||||
|
||||
function getKey(): Buffer {
|
||||
return crypto.createHash('sha256').update(config.appSecret).digest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Chiffre une chaîne de texte.
|
||||
* Retourne une chaîne base64 : iv (16) + tag (16) + ciphertext.
|
||||
*/
|
||||
export function encrypt(plaintext: string): string {
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, getKey(), iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, tag, encrypted]).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Déchiffre une chaîne encodée en base64 produite par encrypt().
|
||||
*/
|
||||
export function decrypt(encoded: string): string {
|
||||
const data = Buffer.from(encoded, 'base64');
|
||||
const iv = data.subarray(0, IV_LENGTH);
|
||||
const tag = data.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
||||
const enc = data.subarray(IV_LENGTH + TAG_LENGTH);
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return decipher.update(enc).toString('utf8') + decipher.final('utf8');
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Pool } from 'pg';
|
||||
import { config } from './config';
|
||||
|
||||
export const db = new Pool({
|
||||
connectionString: config.databaseUrl,
|
||||
// Pas de SSL forcé — connexion interne Docker (réseau privé)
|
||||
max: 10,
|
||||
connectionTimeoutMillis: 10000, // Erreur si connexion impossible après 10s
|
||||
idleTimeoutMillis: 60000, // Libérer connexions inactives après 60s
|
||||
});
|
||||
|
||||
db.on('error', (err) => {
|
||||
console.error('Erreur pool PostgreSQL :', err.message);
|
||||
});
|
||||
|
||||
export async function testConnection(): Promise<void> {
|
||||
const client = await db.connect();
|
||||
client.release();
|
||||
console.log('✅ PostgreSQL connecté');
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { config } from './config';
|
||||
import { db, testConnection } from './db';
|
||||
|
||||
// Routes
|
||||
import authRoutes from './routes/auth';
|
||||
import invoicesRoutes from './routes/invoices';
|
||||
import companiesRoutes from './routes/companies';
|
||||
import categoriesRoutes from './routes/categories';
|
||||
import settingsRoutes from './routes/settings';
|
||||
import contactsRoutes from './routes/contacts';
|
||||
|
||||
const app = express();
|
||||
|
||||
// ─── Sécurité ────────────────────────────────────────────────
|
||||
app.use(helmet({
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
}));
|
||||
app.use(cors({
|
||||
origin: config.frontendUrl,
|
||||
credentials: true,
|
||||
}));
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
// ─── Routes API ──────────────────────────────────────────────
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/invoices', invoicesRoutes);
|
||||
app.use('/api/companies', companiesRoutes);
|
||||
app.use('/api/categories', categoriesRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/contacts', contactsRoutes);
|
||||
|
||||
// ─── Health check (accessible via /health ET /api/health) ──────
|
||||
app.get('/health', (_req, res) => res.json({ status: 'ok', version: '1.0.0' }));
|
||||
app.get('/api/health', (_req, res) => res.json({ status: 'ok', version: '1.0.0', uptime: process.uptime() }));
|
||||
|
||||
// ─── Gestionnaire d'erreurs global ────────────────────────────
|
||||
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
console.error('[Error]', err.stack);
|
||||
res.status(500).json({
|
||||
error: err.message,
|
||||
type: err.constructor.name,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Initialisation DB + migration (arrière-plan) ─────────────
|
||||
async function runMigration(): Promise<void> {
|
||||
const sqlPath = path.join(__dirname, 'migrations/001_init.sql');
|
||||
const sql = fs.readFileSync(sqlPath, 'utf8');
|
||||
await db.query(sql);
|
||||
console.log('✅ Migration terminée');
|
||||
}
|
||||
|
||||
async function runInitUsers(): Promise<void> {
|
||||
const users = [
|
||||
{ name: 'Greg', email: process.env.GREG_EMAIL || 'greg@example.com', password: process.env.GREG_PASSWORD || 'changeme' },
|
||||
{ name: 'Gaël', email: process.env.GAEL_EMAIL || 'gael@example.com', password: process.env.GAEL_PASSWORD || 'changeme' },
|
||||
];
|
||||
for (const user of users) {
|
||||
const hash = await bcrypt.hash(user.password, 12);
|
||||
await db.query(
|
||||
`INSERT INTO users (name, email, password_hash)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name`,
|
||||
[user.name, user.email, hash]
|
||||
);
|
||||
console.log(` ✅ Utilisateur prêt : ${user.name} <${user.email}>`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDb(maxAttempts = 30, delayMs = 2000): Promise<void> {
|
||||
for (let i = 1; i <= maxAttempts; i++) {
|
||||
try {
|
||||
await testConnection();
|
||||
return;
|
||||
} catch {
|
||||
console.log(` PostgreSQL non prêt (${i}/${maxAttempts}), attente...`);
|
||||
await new Promise(r => setTimeout(r, delayMs));
|
||||
}
|
||||
}
|
||||
throw new Error(`PostgreSQL inaccessible après ${maxAttempts} tentatives`);
|
||||
}
|
||||
|
||||
// ─── Démarrage ────────────────────────────────────────────────
|
||||
async function start() {
|
||||
// Créer les répertoires uploads
|
||||
try {
|
||||
fs.mkdirSync(path.join(config.uploadsDir, 'images'), { recursive: true });
|
||||
fs.mkdirSync(path.join(config.uploadsDir, 'pdfs'), { recursive: true });
|
||||
} catch (err: any) {
|
||||
console.warn('Avertissement: impossible de créer les répertoires uploads:', err.message);
|
||||
}
|
||||
|
||||
// ── Bind du port IMMÉDIATEMENT ─────────────────────────────
|
||||
app.listen(config.port, () => {
|
||||
console.log(`🚀 NotesFrais backend démarré sur le port ${config.port}`);
|
||||
console.log(` Environnement : ${config.nodeEnv}`);
|
||||
console.log(` Frontend autorisé : ${config.frontendUrl}`);
|
||||
console.log(` DATABASE_URL: ${process.env.DATABASE_URL
|
||||
? process.env.DATABASE_URL.replace(/:([^@]+)@/, ':***@')
|
||||
: '[non défini — utilise défaut localhost]'}`);
|
||||
});
|
||||
|
||||
// ── Initialisation DB en arrière-plan ─────────────────────
|
||||
(async () => {
|
||||
try {
|
||||
await waitForDb();
|
||||
await runMigration();
|
||||
await runInitUsers();
|
||||
console.log('✅ Base de données prête');
|
||||
} catch (err: any) {
|
||||
console.error('⚠️ Initialisation DB échouée (non bloquant):', err.message);
|
||||
// Le serveur continue — les routes retourneront des 500 si la DB est inaccessible
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
start().catch((err) => {
|
||||
console.error('Impossible de démarrer le serveur :', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { config } from '../config';
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
user?: { id: number; name: string; email: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware JWT — vérifie le Bearer token dans Authorization.
|
||||
* Injecte req.user si valide, sinon 401.
|
||||
*/
|
||||
export function requireAuth(req: AuthRequest, res: Response, next: NextFunction): void {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'Token manquant' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
const payload = jwt.verify(token, config.jwtSecret) as {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
req.user = payload;
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Token invalide ou expiré' });
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { ZodSchema, ZodError } from 'zod';
|
||||
|
||||
/**
|
||||
* Middleware de validation Zod.
|
||||
* Parse req.body avec le schéma fourni.
|
||||
* Retourne 400 avec le détail des erreurs si invalide.
|
||||
*/
|
||||
export function validate(schema: ZodSchema) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
try {
|
||||
req.body = schema.parse(req.body);
|
||||
next();
|
||||
} catch (err) {
|
||||
if (err instanceof ZodError) {
|
||||
res.status(400).json({
|
||||
error: 'Données invalides',
|
||||
details: err.errors.map((e) => ({
|
||||
field: e.path.join('.'),
|
||||
message: e.message,
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
-- =============================================================
|
||||
-- NotesFrais — Migration 001 : Initialisation de la base
|
||||
-- =============================================================
|
||||
|
||||
-- Extension pour gen_random_uuid()
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
-- =============================================================
|
||||
-- UTILISATEURS
|
||||
-- Deux utilisateurs fixes : Greg et Gaël
|
||||
-- Chaque utilisateur possède sa propre config SMTP
|
||||
-- =============================================================
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
-- Config SMTP (expéditeur propre à chaque user)
|
||||
smtp_host VARCHAR(255),
|
||||
smtp_port INTEGER DEFAULT 587,
|
||||
smtp_secure BOOLEAN DEFAULT FALSE, -- TRUE = port 465 / SSL
|
||||
smtp_user VARCHAR(255),
|
||||
smtp_pass_enc TEXT, -- chiffré AES-256 côté app
|
||||
smtp_from_name VARCHAR(100),
|
||||
smtp_from_email VARCHAR(255),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =============================================================
|
||||
-- PARAMÈTRES GLOBAUX DE L'APPLICATION
|
||||
-- Stocke la config Microsoft Graph + localisation du fichier Excel
|
||||
-- Clés attendues : graph_tenant_id, graph_client_id,
|
||||
-- graph_client_secret_enc,
|
||||
-- sharepoint_site_id, sharepoint_item_id,
|
||||
-- sharepoint_sheet_name
|
||||
-- =============================================================
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =============================================================
|
||||
-- SOCIÉTÉS
|
||||
-- Entités vers lesquelles envoyer les factures à rembourser
|
||||
-- =============================================================
|
||||
CREATE TABLE IF NOT EXISTS companies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL, -- destinataire de l'email de remboursement
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =============================================================
|
||||
-- CATÉGORIES DE DÉPENSES
|
||||
-- =============================================================
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =============================================================
|
||||
-- FACTURES (table principale)
|
||||
-- =============================================================
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE RESTRICT,
|
||||
category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE RESTRICT,
|
||||
supplier VARCHAR(255), -- fournisseur (OCR ou saisie manuelle)
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
invoice_date DATE NOT NULL, -- date figurant sur le justificatif
|
||||
comment TEXT, -- commentaire libre
|
||||
-- Fichiers
|
||||
images JSONB DEFAULT '[]', -- [{ "path": "...", "order": 0 }]
|
||||
pdf_path TEXT, -- chemin relatif du PDF généré sur le VPS
|
||||
pdf_filename TEXT, -- ex: 2026-04-29_Restaurant_SocieteA_42.50€.pdf
|
||||
-- Options d'envoi
|
||||
add_to_tracking BOOLEAN DEFAULT TRUE, -- case "Ajouter au fichier de suivi"
|
||||
tracking_added BOOLEAN DEFAULT FALSE,-- envoi SharePoint réalisé
|
||||
email_sent BOOLEAN DEFAULT FALSE,
|
||||
sent_at TIMESTAMPTZ,
|
||||
-- Statut remboursement
|
||||
status VARCHAR(20) DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'reimbursed')),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =============================================================
|
||||
-- INVITÉS (associés à une facture)
|
||||
-- =============================================================
|
||||
CREATE TABLE IF NOT EXISTS guests (
|
||||
id SERIAL PRIMARY KEY,
|
||||
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
company VARCHAR(255),
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =============================================================
|
||||
-- REFRESH TOKENS (authentification JWT)
|
||||
-- =============================================================
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(255) NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =============================================================
|
||||
-- CONTACTS (répertoire d'invités réutilisables)
|
||||
-- Utilisés comme suggestions dans le formulaire de facture
|
||||
-- =============================================================
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
company VARCHAR(255),
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =============================================================
|
||||
-- INDEX
|
||||
-- =============================================================
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_user_id ON invoices(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_company_id ON invoices(company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_category_id ON invoices(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_date ON invoices(invoice_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_sent_at ON invoices(sent_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_guests_invoice_id ON guests(invoice_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
|
||||
|
||||
-- =============================================================
|
||||
-- TRIGGER : mise à jour automatique de updated_at
|
||||
-- =============================================================
|
||||
CREATE OR REPLACE FUNCTION trigger_set_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE TRIGGER set_updated_at_users
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
|
||||
|
||||
CREATE OR REPLACE TRIGGER set_updated_at_companies
|
||||
BEFORE UPDATE ON companies
|
||||
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
|
||||
|
||||
CREATE OR REPLACE TRIGGER set_updated_at_invoices
|
||||
BEFORE UPDATE ON invoices
|
||||
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
|
||||
|
||||
-- =============================================================
|
||||
-- DONNÉES INITIALES
|
||||
-- =============================================================
|
||||
|
||||
-- Catégories pré-configurées
|
||||
INSERT INTO categories (name, sort_order) VALUES
|
||||
('Restaurant', 1),
|
||||
('Transport', 2),
|
||||
('Hôtel', 3),
|
||||
('Matériel', 4)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Note : les utilisateurs Greg et Gaël sont créés via le script
|
||||
-- d'initialisation (voir README — init-users.sh) afin de ne pas
|
||||
-- stocker de mots de passe en clair dans les migrations.
|
||||
@@ -1,134 +0,0 @@
|
||||
import express, { Router, Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import crypto from 'crypto';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db';
|
||||
import { config } from '../config';
|
||||
import { validate } from '../middleware/validate';
|
||||
import { requireAuth, AuthRequest } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────
|
||||
|
||||
function generateAccessToken(user: { id: number; name: string; email: string }): string {
|
||||
return jwt.sign(
|
||||
{ id: user.id, name: user.name, email: user.email },
|
||||
config.jwtSecret,
|
||||
{ expiresIn: config.jwtExpiresIn as jwt.SignOptions['expiresIn'] }
|
||||
);
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
// ─── Schémas de validation ───────────────────────────────────
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Email invalide'),
|
||||
password: z.string().min(1, 'Mot de passe requis'),
|
||||
});
|
||||
|
||||
// ─── Routes ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* Body: { email, password }
|
||||
* Retourne: { accessToken, refreshToken, user }
|
||||
*/
|
||||
router.post('/login', validate(loginSchema), async (req: Request, res: Response, next: express.NextFunction): Promise<void> => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
const result = await db.query('SELECT * FROM users WHERE email = $1', [email]);
|
||||
const user = result.rows[0];
|
||||
|
||||
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
|
||||
res.status(401).json({ error: 'Email ou mot de passe incorrect' });
|
||||
return;
|
||||
}
|
||||
|
||||
const accessToken = generateAccessToken(user);
|
||||
const refreshToken = crypto.randomBytes(40).toString('hex');
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + config.refreshTokenExpiresDays);
|
||||
|
||||
await db.query(
|
||||
'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
|
||||
[user.id, hashToken(refreshToken), expiresAt]
|
||||
);
|
||||
|
||||
res.json({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: { id: user.id, name: user.name, email: user.email },
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/refresh
|
||||
* Body: { refreshToken }
|
||||
* Retourne: { accessToken }
|
||||
*/
|
||||
router.post('/refresh', async (req: Request, res: Response): Promise<void> => {
|
||||
const { refreshToken } = req.body;
|
||||
if (!refreshToken) {
|
||||
res.status(400).json({ error: 'Refresh token manquant' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await db.query(
|
||||
`SELECT rt.user_id, u.name, u.email
|
||||
FROM refresh_tokens rt
|
||||
JOIN users u ON u.id = rt.user_id
|
||||
WHERE rt.token_hash = $1 AND rt.expires_at > NOW()`,
|
||||
[hashToken(refreshToken)]
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
res.status(401).json({ error: 'Session expirée, veuillez vous reconnecter' });
|
||||
return;
|
||||
}
|
||||
|
||||
const accessToken = generateAccessToken({ id: row.user_id, name: row.name, email: row.email });
|
||||
res.json({ accessToken });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* Révoque le refresh token.
|
||||
*/
|
||||
router.post('/logout', requireAuth, async (req: AuthRequest, res: Response, next: express.NextFunction): Promise<void> => {
|
||||
try {
|
||||
const { refreshToken } = req.body;
|
||||
if (refreshToken) {
|
||||
await db.query('DELETE FROM refresh_tokens WHERE token_hash = $1', [hashToken(refreshToken)]);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
* Retourne l'utilisateur connecté (sans données sensibles).
|
||||
*/
|
||||
router.get('/me', requireAuth, async (req: AuthRequest, res: Response, next: express.NextFunction): Promise<void> => {
|
||||
try {
|
||||
const result = await db.query(
|
||||
`SELECT id, name, email,
|
||||
smtp_host, smtp_port, smtp_secure, smtp_user,
|
||||
smtp_from_name, smtp_from_email
|
||||
FROM users WHERE id = $1`,
|
||||
[req.user!.id]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db';
|
||||
import { requireAuth, AuthRequest } from '../middleware/auth';
|
||||
import { validate } from '../middleware/validate';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const wrap = (fn: (req: AuthRequest, res: Response, next: NextFunction) => Promise<void>) =>
|
||||
(req: AuthRequest, res: Response, next: NextFunction) => fn(req, res, next).catch(next);
|
||||
|
||||
const categorySchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
sort_order: z.number().int().optional().default(0),
|
||||
});
|
||||
|
||||
/** GET /api/categories */
|
||||
router.get('/', wrap(async (_req: AuthRequest, res: Response): Promise<void> => {
|
||||
const result = await db.query(
|
||||
'SELECT * FROM categories WHERE is_active=TRUE ORDER BY sort_order, name'
|
||||
);
|
||||
res.json(result.rows);
|
||||
}));
|
||||
|
||||
/** POST /api/categories */
|
||||
router.post('/', validate(categorySchema), wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const { name, sort_order } = req.body;
|
||||
const result = await db.query(
|
||||
'INSERT INTO categories (name, sort_order) VALUES ($1, $2) RETURNING *',
|
||||
[name, sort_order]
|
||||
);
|
||||
res.status(201).json(result.rows[0]);
|
||||
}));
|
||||
|
||||
/** PUT /api/categories/:id */
|
||||
router.put('/:id', validate(categorySchema), wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const { name, sort_order } = req.body;
|
||||
const result = await db.query(
|
||||
'UPDATE categories SET name=$1, sort_order=$2 WHERE id=$3 AND is_active=TRUE RETURNING *',
|
||||
[name, sort_order, req.params.id]
|
||||
);
|
||||
if (!result.rows[0]) { res.status(404).json({ error: 'Catégorie introuvable' }); return; }
|
||||
res.json(result.rows[0]);
|
||||
}));
|
||||
|
||||
/** DELETE /api/categories/:id — Soft-delete */
|
||||
router.delete('/:id', wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
await db.query('UPDATE categories SET is_active=FALSE WHERE id=$1', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
}));
|
||||
|
||||
export default router;
|
||||
@@ -1,57 +0,0 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db';
|
||||
import { requireAuth, AuthRequest } from '../middleware/auth';
|
||||
import { validate } from '../middleware/validate';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const wrap = (fn: (req: AuthRequest, res: Response, next: NextFunction) => Promise<void>) =>
|
||||
(req: AuthRequest, res: Response, next: NextFunction) => fn(req, res, next).catch(next);
|
||||
|
||||
const companySchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
/** GET /api/companies — Liste toutes les sociétés actives */
|
||||
router.get('/', wrap(async (_req: AuthRequest, res: Response): Promise<void> => {
|
||||
const result = await db.query(
|
||||
'SELECT * FROM companies WHERE is_active = TRUE ORDER BY name'
|
||||
);
|
||||
res.json(result.rows);
|
||||
}));
|
||||
|
||||
/** POST /api/companies — Crée une société */
|
||||
router.post('/', validate(companySchema), wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const { name, email } = req.body;
|
||||
const result = await db.query(
|
||||
`INSERT INTO companies (name, email) VALUES ($1, $2) RETURNING *`,
|
||||
[name, email]
|
||||
);
|
||||
res.status(201).json(result.rows[0]);
|
||||
}));
|
||||
|
||||
/** PUT /api/companies/:id — Met à jour une société */
|
||||
router.put('/:id', validate(companySchema), wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const { name, email } = req.body;
|
||||
const result = await db.query(
|
||||
`UPDATE companies SET name=$1, email=$2, updated_at=NOW()
|
||||
WHERE id=$3 AND is_active=TRUE RETURNING *`,
|
||||
[name, email, req.params.id]
|
||||
);
|
||||
if (!result.rows[0]) { res.status(404).json({ error: 'Société introuvable' }); return; }
|
||||
res.json(result.rows[0]);
|
||||
}));
|
||||
|
||||
/** DELETE /api/companies/:id — Soft-delete (is_active = false) */
|
||||
router.delete('/:id', wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
await db.query(
|
||||
'UPDATE companies SET is_active=FALSE, updated_at=NOW() WHERE id=$1',
|
||||
[req.params.id]
|
||||
);
|
||||
res.json({ success: true });
|
||||
}));
|
||||
|
||||
export default router;
|
||||
@@ -1,150 +0,0 @@
|
||||
import { Router, RequestHandler } from 'express';
|
||||
import multer from 'multer';
|
||||
import { db } from '../db';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import { parse as csvParse } from 'csv-parse/sync';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// Multer en mémoire (CSV/XLSX, max 5 Mo)
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const ok = [
|
||||
'text/csv',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/octet-stream',
|
||||
].includes(file.mimetype) || file.originalname.match(/\.(csv|xls|xlsx)$/i);
|
||||
cb(null, !!ok);
|
||||
},
|
||||
});
|
||||
|
||||
function wrap(fn: RequestHandler): RequestHandler {
|
||||
return (req, res, next) => (fn(req, res, next) as any).catch(next);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Normalise une ligne brute en { name, company } */
|
||||
function normalizeRow(row: Record<string, any>): { name: string; company: string | null } | null {
|
||||
// Cherche les colonnes "nom"/"name" et "société"/"company" indépendamment de la casse
|
||||
const keys = Object.keys(row);
|
||||
|
||||
const nameKey = keys.find((k) =>
|
||||
/^(nom|name|prénom|prenom|contact)$/i.test(k.trim())
|
||||
);
|
||||
const companyKey = keys.find((k) =>
|
||||
/^(soci[eé]t[eé]|company|entreprise|organisation|organization|employeur|employer)$/i.test(k.trim())
|
||||
);
|
||||
|
||||
// Fallback : première colonne = nom, deuxième = société
|
||||
const name = (nameKey ? row[nameKey] : row[keys[0]] ?? '').toString().trim();
|
||||
const company = companyKey
|
||||
? (row[companyKey] ?? '').toString().trim() || null
|
||||
: keys[1]
|
||||
? (row[keys[1]] ?? '').toString().trim() || null
|
||||
: null;
|
||||
|
||||
if (!name) return null;
|
||||
return { name, company };
|
||||
}
|
||||
|
||||
/** Parse un buffer CSV → tableau de lignes normalisées */
|
||||
function parseCSV(buf: Buffer): Array<{ name: string; company: string | null }> {
|
||||
const rows = csvParse(buf, {
|
||||
columns: true,
|
||||
skip_empty_lines: true,
|
||||
trim: true,
|
||||
bom: true,
|
||||
}) as Record<string, any>[];
|
||||
return rows.map(normalizeRow).filter(Boolean) as Array<{ name: string; company: string | null }>;
|
||||
}
|
||||
|
||||
/** Parse un buffer XLSX → tableau de lignes normalisées */
|
||||
function parseXLSX(buf: Buffer): Array<{ name: string; company: string | null }> {
|
||||
const wb = XLSX.read(buf, { type: 'buffer' });
|
||||
const ws = wb.Sheets[wb.SheetNames[0]];
|
||||
const rows = XLSX.utils.sheet_to_json<Record<string, any>>(ws, { defval: '' });
|
||||
return rows.map(normalizeRow).filter(Boolean) as Array<{ name: string; company: string | null }>;
|
||||
}
|
||||
|
||||
// ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** GET /api/contacts — liste tous les contacts triés */
|
||||
router.get('/', wrap(async (_req, res) => {
|
||||
const { rows } = await db.query(
|
||||
'SELECT id, name, company, sort_order FROM contacts ORDER BY name ASC'
|
||||
);
|
||||
res.json(rows);
|
||||
}));
|
||||
|
||||
/** POST /api/contacts — ajoute un contact manuel */
|
||||
router.post('/', wrap(async (req, res) => {
|
||||
const { name, company } = req.body as { name?: string; company?: string };
|
||||
if (!name?.trim()) {
|
||||
return res.status(400).json({ error: 'Le nom est requis.' });
|
||||
}
|
||||
const { rows } = await db.query(
|
||||
'INSERT INTO contacts (name, company) VALUES ($1, $2) RETURNING id, name, company, sort_order',
|
||||
[name.trim(), company?.trim() || null]
|
||||
);
|
||||
res.status(201).json(rows[0]);
|
||||
}));
|
||||
|
||||
/** POST /api/contacts/import — import CSV ou XLSX */
|
||||
router.post('/import', upload.single('file'), wrap(async (req, res) => {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'Aucun fichier fourni.' });
|
||||
}
|
||||
|
||||
const isXLSX =
|
||||
req.file.originalname.match(/\.(xls|xlsx)$/i) ||
|
||||
req.file.mimetype.includes('spreadsheet') ||
|
||||
req.file.mimetype.includes('ms-excel');
|
||||
|
||||
let contacts: Array<{ name: string; company: string | null }>;
|
||||
try {
|
||||
contacts = isXLSX ? parseXLSX(req.file.buffer) : parseCSV(req.file.buffer);
|
||||
} catch (err: any) {
|
||||
return res.status(422).json({ error: `Impossible de lire le fichier : ${err.message}` });
|
||||
}
|
||||
|
||||
if (contacts.length === 0) {
|
||||
return res.status(422).json({ error: 'Aucun contact trouvé dans le fichier. Vérifiez les colonnes (Nom, Société).' });
|
||||
}
|
||||
|
||||
// Upsert : si (name, company) existe déjà, on ne duplique pas
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const c of contacts) {
|
||||
const exists = await db.query(
|
||||
'SELECT id FROM contacts WHERE LOWER(name) = LOWER($1) AND (company IS NULL AND $2::TEXT IS NULL OR LOWER(company) = LOWER($2))',
|
||||
[c.name, c.company]
|
||||
);
|
||||
if (exists.rowCount && exists.rowCount > 0) {
|
||||
skipped++;
|
||||
} else {
|
||||
await db.query(
|
||||
'INSERT INTO contacts (name, company) VALUES ($1, $2)',
|
||||
[c.name, c.company]
|
||||
);
|
||||
inserted++;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ inserted, skipped, total: contacts.length });
|
||||
}));
|
||||
|
||||
/** DELETE /api/contacts/:id */
|
||||
router.delete('/:id', wrap(async (req, res) => {
|
||||
const { rowCount } = await db.query('DELETE FROM contacts WHERE id = $1', [req.params.id]);
|
||||
if (!rowCount) return res.status(404).json({ error: 'Contact introuvable.' });
|
||||
res.json({ success: true });
|
||||
}));
|
||||
|
||||
export default router;
|
||||
@@ -1,447 +0,0 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { db } from '../db';
|
||||
import { requireAuth, AuthRequest } from '../middleware/auth';
|
||||
import { validate } from '../middleware/validate';
|
||||
import { config } from '../config';
|
||||
import { generateInvoicePdf } from '../services/pdf';
|
||||
import { sendInvoiceEmail } from '../services/email';
|
||||
import { addRowToExcel } from '../services/sharepoint';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// Wrapper pour éviter les unhandledRejection qui crashent Node 20
|
||||
function wrap(fn: (req: AuthRequest, res: Response, next: NextFunction) => Promise<void>) {
|
||||
return (req: AuthRequest, res: Response, next: NextFunction) => fn(req, res, next).catch(next);
|
||||
}
|
||||
|
||||
// ─── Upload images ────────────────────────────────────────────
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (_req, _file, cb) => {
|
||||
const dir = path.join(config.uploadsDir, 'images');
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase() || '.jpg';
|
||||
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 15 * 1024 * 1024 }, // 15 Mo
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
cb(null, allowed.includes(file.mimetype));
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/invoices/upload-image
|
||||
* Upload d'une photo de facture (avant soumission du formulaire).
|
||||
* Retourne: { filename }
|
||||
*/
|
||||
router.post('/upload-image', upload.single('image'), wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: 'Aucun fichier reçu ou format non supporté (jpg/png/webp)' });
|
||||
return;
|
||||
}
|
||||
res.json({ filename: req.file.filename });
|
||||
}));
|
||||
|
||||
// ─── Schémas de validation ────────────────────────────────────
|
||||
|
||||
const createSchema = z.object({
|
||||
company_id: z.number().int().positive(),
|
||||
category_id: z.number().int().positive(),
|
||||
supplier: z.string().optional(),
|
||||
amount: z.number().positive('Le montant doit être positif'),
|
||||
invoice_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Format AAAA-MM-JJ attendu'),
|
||||
comment: z.string().optional(),
|
||||
images: z.array(z.object({
|
||||
path: z.string().min(1),
|
||||
order: z.number().int(),
|
||||
})).min(1, 'Au moins une image requise'),
|
||||
add_to_tracking: z.boolean().default(true),
|
||||
guests: z.array(z.object({
|
||||
name: z.string().min(1),
|
||||
company: z.string().optional().nullable(),
|
||||
sort_order: z.number().int().optional().default(0),
|
||||
})).default([]),
|
||||
});
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
async function getInvoiceById(id: string) {
|
||||
const result = await db.query(
|
||||
`SELECT i.*,
|
||||
co.name AS company_name,
|
||||
co.email AS company_email,
|
||||
cat.name AS category_name,
|
||||
u.name AS user_name
|
||||
FROM invoices i
|
||||
JOIN companies co ON co.id = i.company_id
|
||||
JOIN categories cat ON cat.id = i.category_id
|
||||
JOIN users u ON u.id = i.user_id
|
||||
WHERE i.id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (!result.rows[0]) return null;
|
||||
|
||||
const invoice = result.rows[0];
|
||||
const guests = await db.query(
|
||||
'SELECT name, company, sort_order FROM guests WHERE invoice_id=$1 ORDER BY sort_order',
|
||||
[id]
|
||||
);
|
||||
invoice.guests = guests.rows;
|
||||
return invoice;
|
||||
}
|
||||
|
||||
// ─── Routes ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/invoices/summary
|
||||
* Récapitulatif des montants par société × statut (En attente / Remboursé)
|
||||
* ⚠ Doit être AVANT /:id pour ne pas être capturé
|
||||
*/
|
||||
router.get('/summary', wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const result = await db.query(
|
||||
`SELECT co.name AS company_name, i.status,
|
||||
SUM(i.amount) AS total,
|
||||
COUNT(*)::int AS count
|
||||
FROM invoices i
|
||||
JOIN companies co ON co.id = i.company_id
|
||||
WHERE i.user_id = $1
|
||||
GROUP BY co.name, i.status
|
||||
ORDER BY co.name, i.status`,
|
||||
[req.user!.id]
|
||||
);
|
||||
res.json(result.rows);
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /api/invoices/export/csv
|
||||
* Export CSV du listing filtré (mêmes filtres que GET /)
|
||||
*/
|
||||
router.get('/export/csv', wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const { company_ids, category_ids, status, date_from, date_to, search } = req.query;
|
||||
|
||||
const conditions: string[] = ['i.user_id = $1'];
|
||||
const params: unknown[] = [req.user!.id];
|
||||
let p = 2;
|
||||
|
||||
if (company_ids) { conditions.push(`i.company_id = ANY($${p++}::int[])`); params.push(String(company_ids).split(',').map(Number)); }
|
||||
if (category_ids) { conditions.push(`i.category_id = ANY($${p++}::int[])`); params.push(String(category_ids).split(',').map(Number)); }
|
||||
if (status) { conditions.push(`i.status = $${p++}`); params.push(status); }
|
||||
if (date_from) { conditions.push(`i.invoice_date >= $${p++}`); params.push(date_from); }
|
||||
if (date_to) { conditions.push(`i.invoice_date <= $${p++}`); params.push(date_to); }
|
||||
if (search) { conditions.push(`(i.supplier ILIKE $${p} OR i.comment ILIKE $${p})`); params.push(`%${search}%`); p++; }
|
||||
|
||||
const result = await db.query(
|
||||
`SELECT i.invoice_date, co.name AS company, cat.name AS category,
|
||||
i.supplier, i.amount, i.comment, i.status,
|
||||
i.sent_at, i.pdf_filename
|
||||
FROM invoices i
|
||||
JOIN companies co ON co.id = i.company_id
|
||||
JOIN categories cat ON cat.id = i.category_id
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY COALESCE(i.sent_at, i.created_at) DESC`,
|
||||
params
|
||||
);
|
||||
|
||||
const headers = ['Date','Société','Catégorie','Fournisseur','Montant (€)','Commentaire','Statut','Date envoi','Fichier PDF'];
|
||||
const csvLines = [headers.join(';')];
|
||||
|
||||
for (const row of result.rows) {
|
||||
const invoiceDate = row.invoice_date
|
||||
? new Date(row.invoice_date).toLocaleDateString('fr-FR')
|
||||
: '';
|
||||
const sentDate = row.sent_at
|
||||
? new Date(row.sent_at).toLocaleDateString('fr-FR')
|
||||
: '';
|
||||
|
||||
csvLines.push([
|
||||
invoiceDate,
|
||||
row.company,
|
||||
row.category,
|
||||
row.supplier || '',
|
||||
Number(row.amount).toFixed(2).replace('.', ','),
|
||||
`"${(row.comment || '').replace(/"/g, '""')}"`,
|
||||
row.status === 'pending' ? 'En attente' : 'Remboursé',
|
||||
sentDate,
|
||||
row.pdf_filename || '',
|
||||
].join(';'));
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="factures-${new Date().toISOString().split('T')[0]}.csv"`);
|
||||
res.send('' + csvLines.join('\n')); // BOM pour Excel
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /api/invoices
|
||||
* Liste paginée avec filtres combinables
|
||||
*/
|
||||
router.get('/', wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const { company_ids, category_ids, status, date_from, date_to, search,
|
||||
sort_by, sort_dir, page, limit } = req.query;
|
||||
|
||||
const conditions: string[] = ['i.user_id = $1'];
|
||||
const params: unknown[] = [req.user!.id];
|
||||
let p = 2;
|
||||
|
||||
if (company_ids) { conditions.push(`i.company_id = ANY($${p++}::int[])`); params.push(String(company_ids).split(',').map(Number)); }
|
||||
if (category_ids) { conditions.push(`i.category_id = ANY($${p++}::int[])`); params.push(String(category_ids).split(',').map(Number)); }
|
||||
if (status) { conditions.push(`i.status = $${p++}`); params.push(status); }
|
||||
if (date_from) { conditions.push(`i.invoice_date >= $${p++}`); params.push(date_from); }
|
||||
if (date_to) { conditions.push(`i.invoice_date <= $${p++}`); params.push(date_to); }
|
||||
if (search) { conditions.push(`(i.supplier ILIKE $${p} OR i.comment ILIKE $${p})`); params.push(`%${search}%`); p++; }
|
||||
|
||||
// Tri sécurisé
|
||||
const sortMap: Record<string, string> = {
|
||||
invoice_date: 'i.invoice_date',
|
||||
amount: 'i.amount',
|
||||
supplier: 'i.supplier',
|
||||
company_name: 'co.name',
|
||||
category_name: 'cat.name',
|
||||
status: 'i.status',
|
||||
sent_at: 'i.sent_at',
|
||||
};
|
||||
const orderCol = sortMap[String(sort_by)] ?? 'i.sent_at';
|
||||
const orderDir = sort_dir === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
const pageNum = Math.max(1, parseInt(String(page || '1')));
|
||||
const limitNum = Math.min(100, Math.max(1, parseInt(String(limit || '50'))));
|
||||
const offset = (pageNum - 1) * limitNum;
|
||||
const where = conditions.join(' AND ');
|
||||
|
||||
const [dataRes, countRes] = await Promise.all([
|
||||
db.query(
|
||||
`SELECT i.id, i.supplier, i.amount, i.invoice_date, i.comment,
|
||||
i.status, i.email_sent, i.tracking_added, i.sent_at,
|
||||
i.add_to_tracking, i.pdf_filename, i.created_at,
|
||||
co.name AS company_name,
|
||||
cat.name AS category_name
|
||||
FROM invoices i
|
||||
JOIN companies co ON co.id = i.company_id
|
||||
JOIN categories cat ON cat.id = i.category_id
|
||||
WHERE ${where}
|
||||
ORDER BY ${orderCol} ${orderDir} NULLS LAST
|
||||
LIMIT $${p} OFFSET $${p + 1}`,
|
||||
[...params, limitNum, offset]
|
||||
),
|
||||
db.query(
|
||||
`SELECT COUNT(*)::int AS total
|
||||
FROM invoices i
|
||||
JOIN companies co ON co.id = i.company_id
|
||||
JOIN categories cat ON cat.id = i.category_id
|
||||
WHERE ${where}`,
|
||||
params
|
||||
),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
data: dataRes.rows,
|
||||
total: countRes.rows[0].total,
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* POST /api/invoices
|
||||
* Crée une facture (avec invités)
|
||||
*/
|
||||
router.post('/', validate(createSchema), wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const { company_id, category_id, supplier, amount, invoice_date,
|
||||
comment, images, add_to_tracking, guests } = req.body;
|
||||
|
||||
const client = await db.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const invResult = await client.query(
|
||||
`INSERT INTO invoices
|
||||
(user_id, company_id, category_id, supplier, amount,
|
||||
invoice_date, comment, images, add_to_tracking)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
RETURNING *`,
|
||||
[req.user!.id, company_id, category_id, supplier ?? null, amount,
|
||||
invoice_date, comment ?? null, JSON.stringify(images), add_to_tracking]
|
||||
);
|
||||
const invoice = invResult.rows[0];
|
||||
|
||||
for (const g of guests) {
|
||||
await client.query(
|
||||
'INSERT INTO guests (invoice_id, name, company, sort_order) VALUES ($1,$2,$3,$4)',
|
||||
[invoice.id, g.name, g.company ?? null, g.sort_order ?? 0]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.status(201).json(await getInvoiceById(invoice.id));
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}));
|
||||
|
||||
/**
|
||||
* POST /api/invoices/:id/send
|
||||
* 1. Génère le PDF (images + page invités si applicable)
|
||||
* 2. Envoie l'email à la société
|
||||
* 3. Ajoute au fichier Excel SharePoint (si add_to_tracking = true)
|
||||
* 4. Met à jour la facture en BDD
|
||||
*/
|
||||
router.post('/:id/send', wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const invoice = await getInvoiceById(req.params.id);
|
||||
if (!invoice) { res.status(404).json({ error: 'Facture introuvable' }); return; }
|
||||
if (invoice.user_id !== req.user!.id) { res.status(403).json({ error: 'Accès refusé' }); return; }
|
||||
|
||||
const userResult = await db.query('SELECT * FROM users WHERE id=$1', [req.user!.id]);
|
||||
const user = userResult.rows[0];
|
||||
|
||||
if (!user.smtp_host || !user.smtp_pass_enc) {
|
||||
res.status(400).json({ error: 'SMTP non configuré. Allez dans Paramètres → Email.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Nommage du PDF ────────────────────────────────────────
|
||||
const dateObj = new Date(invoice.invoice_date);
|
||||
const dateStr = dateObj.toISOString().split('T')[0]; // AAAA-MM-JJ
|
||||
const amountStr = Number(invoice.amount).toFixed(2).replace('.', ',');
|
||||
const safeCat = invoice.category_name.replace(/[^a-zA-Z0-9À-ɏ]/g, '-');
|
||||
const safeCo = invoice.company_name.replace(/[^a-zA-Z0-9À-ɏ]/g, '-');
|
||||
const pdfFilename = `${dateStr}_${safeCat}_${safeCo}_${amountStr}€.pdf`;
|
||||
|
||||
const pdfDir = path.join(config.uploadsDir, 'pdfs');
|
||||
const pdfPath = path.join(pdfDir, `${invoice.id}.pdf`);
|
||||
|
||||
// ── Génération PDF ────────────────────────────────────────
|
||||
const imagePaths = (invoice.images as Array<{ path: string; order: number }>)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((img) => path.join(config.uploadsDir, 'images', img.path));
|
||||
|
||||
await generateInvoicePdf(imagePaths, invoice.guests, pdfPath, user.name);
|
||||
|
||||
// ── Envoi email ───────────────────────────────────────────
|
||||
await sendInvoiceEmail(user, invoice.company_email, pdfPath, pdfFilename);
|
||||
|
||||
// ── SharePoint (non bloquant) ─────────────────────────────
|
||||
let trackingAdded = false;
|
||||
let trackingError: string | null = null;
|
||||
if (invoice.add_to_tracking) {
|
||||
try {
|
||||
const [year, month, day] = dateStr.split('-');
|
||||
await addRowToExcel({
|
||||
category: invoice.category_name,
|
||||
companyName: invoice.company_name,
|
||||
comment: invoice.comment || '',
|
||||
guests: invoice.guests,
|
||||
date: `${day}/${month}/${year}`,
|
||||
amount: Number(invoice.amount),
|
||||
userName: user.name,
|
||||
});
|
||||
trackingAdded = true;
|
||||
} catch (err: any) {
|
||||
// Non bloquant : l'email est déjà envoyé, mais on remonte l'erreur
|
||||
// pour que le frontend puisse afficher un avertissement.
|
||||
console.warn('[SharePoint] Erreur non bloquante :', err.message);
|
||||
trackingError = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mise à jour BDD ───────────────────────────────────────
|
||||
await db.query(
|
||||
`UPDATE invoices
|
||||
SET pdf_path=$1, pdf_filename=$2, email_sent=TRUE,
|
||||
tracking_added=$3, sent_at=NOW(), updated_at=NOW()
|
||||
WHERE id=$4`,
|
||||
[path.join('pdfs', `${invoice.id}.pdf`), pdfFilename, trackingAdded, invoice.id]
|
||||
);
|
||||
|
||||
const updated = await getInvoiceById(invoice.id);
|
||||
res.json({ ...updated, tracking_error: trackingError });
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /api/invoices/:id
|
||||
*/
|
||||
router.get('/:id', wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const invoice = await getInvoiceById(req.params.id);
|
||||
if (!invoice || invoice.user_id !== req.user!.id) {
|
||||
res.status(404).json({ error: 'Facture introuvable' });
|
||||
return;
|
||||
}
|
||||
res.json(invoice);
|
||||
}));
|
||||
|
||||
/**
|
||||
* PATCH /api/invoices/:id/status
|
||||
* Toggle du statut (pending ↔ reimbursed)
|
||||
*/
|
||||
router.patch('/:id/status', wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const { status } = req.body;
|
||||
if (!['pending', 'reimbursed'].includes(status)) {
|
||||
res.status(400).json({ error: 'Statut invalide (pending ou reimbursed)' });
|
||||
return;
|
||||
}
|
||||
const result = await db.query(
|
||||
'UPDATE invoices SET status=$1, updated_at=NOW() WHERE id=$2 AND user_id=$3 RETURNING id, status',
|
||||
[status, req.params.id, req.user!.id]
|
||||
);
|
||||
if (!result.rows[0]) { res.status(404).json({ error: 'Facture introuvable' }); return; }
|
||||
res.json(result.rows[0]);
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /api/invoices/:id/pdf
|
||||
* Téléchargement du PDF généré
|
||||
*/
|
||||
router.get('/:id/pdf', wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const result = await db.query(
|
||||
'SELECT pdf_path, pdf_filename FROM invoices WHERE id=$1 AND user_id=$2',
|
||||
[req.params.id, req.user!.id]
|
||||
);
|
||||
const invoice = result.rows[0];
|
||||
if (!invoice?.pdf_path) { res.status(404).json({ error: 'PDF non disponible' }); return; }
|
||||
|
||||
const fullPath = path.join(config.uploadsDir, invoice.pdf_path);
|
||||
res.download(fullPath, invoice.pdf_filename);
|
||||
}));
|
||||
|
||||
/**
|
||||
* DELETE /api/invoices/:id
|
||||
* Supprime la facture + nettoyage des fichiers
|
||||
*/
|
||||
router.delete('/:id', wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const result = await db.query(
|
||||
'DELETE FROM invoices WHERE id=$1 AND user_id=$2 RETURNING id, pdf_path, images',
|
||||
[req.params.id, req.user!.id]
|
||||
);
|
||||
if (!result.rows[0]) { res.status(404).json({ error: 'Facture introuvable' }); return; }
|
||||
|
||||
const deleted = result.rows[0];
|
||||
|
||||
// Nettoyage PDF
|
||||
if (deleted.pdf_path) {
|
||||
await fs.unlink(path.join(config.uploadsDir, deleted.pdf_path)).catch(() => {});
|
||||
}
|
||||
// Nettoyage images sources
|
||||
const images = (deleted.images as Array<{ path: string }>) || [];
|
||||
for (const img of images) {
|
||||
await fs.unlink(path.join(config.uploadsDir, 'images', img.path)).catch(() => {});
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
}));
|
||||
|
||||
export default router;
|
||||
@@ -1,154 +0,0 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db';
|
||||
import { requireAuth, AuthRequest } from '../middleware/auth';
|
||||
import { validate } from '../middleware/validate';
|
||||
import { encrypt } from '../crypto';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const wrap = (fn: (req: AuthRequest, res: Response, next: NextFunction) => Promise<void>) =>
|
||||
(req: AuthRequest, res: Response, next: NextFunction) => fn(req, res, next).catch(next);
|
||||
|
||||
// ─── Schémas ─────────────────────────────────────────────────
|
||||
|
||||
const smtpSchema = z.object({
|
||||
smtp_host: z.string().min(1, 'Hôte SMTP requis'),
|
||||
smtp_port: z.number().int().min(1).max(65535),
|
||||
smtp_secure: z.boolean(),
|
||||
smtp_user: z.string().min(1),
|
||||
smtp_pass: z.string().optional(), // absent = conserver l'existant
|
||||
smtp_from_name: z.string().min(1),
|
||||
smtp_from_email: z.string().email(),
|
||||
});
|
||||
|
||||
const appSettingsSchema = z.object({
|
||||
graph_tenant_id: z.string().optional(),
|
||||
graph_client_id: z.string().optional(),
|
||||
graph_client_secret: z.string().optional(), // absent = conserver l'existant
|
||||
sharepoint_site_id: z.string().optional(),
|
||||
sharepoint_item_id: z.string().optional(),
|
||||
sharepoint_sheet_name: z.string().optional(),
|
||||
});
|
||||
|
||||
// ─── Routes SMTP ─────────────────────────────────────────────
|
||||
|
||||
/** GET /api/settings/smtp — Config SMTP de l'utilisateur (sans mot de passe) */
|
||||
router.get('/smtp', wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const result = await db.query(
|
||||
`SELECT smtp_host, smtp_port, smtp_secure, smtp_user,
|
||||
smtp_from_name, smtp_from_email,
|
||||
(smtp_pass_enc IS NOT NULL) AS has_password
|
||||
FROM users WHERE id=$1`,
|
||||
[req.user!.id]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
}));
|
||||
|
||||
/** PUT /api/settings/smtp — Sauvegarde la config SMTP */
|
||||
router.put('/smtp', validate(smtpSchema), wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const { smtp_host, smtp_port, smtp_secure, smtp_user, smtp_pass, smtp_from_name, smtp_from_email } = req.body;
|
||||
|
||||
if (smtp_pass) {
|
||||
await db.query(
|
||||
`UPDATE users
|
||||
SET smtp_host=$1, smtp_port=$2, smtp_secure=$3, smtp_user=$4,
|
||||
smtp_pass_enc=$5, smtp_from_name=$6, smtp_from_email=$7, updated_at=NOW()
|
||||
WHERE id=$8`,
|
||||
[smtp_host, smtp_port, smtp_secure, smtp_user, encrypt(smtp_pass), smtp_from_name, smtp_from_email, req.user!.id]
|
||||
);
|
||||
} else {
|
||||
await db.query(
|
||||
`UPDATE users
|
||||
SET smtp_host=$1, smtp_port=$2, smtp_secure=$3, smtp_user=$4,
|
||||
smtp_from_name=$5, smtp_from_email=$6, updated_at=NOW()
|
||||
WHERE id=$7`,
|
||||
[smtp_host, smtp_port, smtp_secure, smtp_user, smtp_from_name, smtp_from_email, req.user!.id]
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
}));
|
||||
|
||||
/** POST /api/settings/smtp/test — Envoie un email de test */
|
||||
router.post('/smtp/test', wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const userResult = await db.query('SELECT * FROM users WHERE id=$1', [req.user!.id]);
|
||||
const user = userResult.rows[0];
|
||||
|
||||
if (!user.smtp_host || !user.smtp_pass_enc) {
|
||||
res.status(400).json({ error: 'SMTP non configuré' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { sendTestEmail } = await import('../services/email');
|
||||
await sendTestEmail(user);
|
||||
res.json({ success: true, message: `Email de test envoyé à ${user.smtp_from_email}` });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: `Échec SMTP : ${err.message}` });
|
||||
}
|
||||
}));
|
||||
|
||||
// ─── Routes paramètres application (Microsoft Graph) ─────────
|
||||
|
||||
/** GET /api/settings/app — Config Graph + SharePoint (sans secret) */
|
||||
router.get('/app', wrap(async (_req: AuthRequest, res: Response): Promise<void> => {
|
||||
const result = await db.query(
|
||||
`SELECT key, value FROM app_settings
|
||||
WHERE key IN (
|
||||
'graph_tenant_id','graph_client_id',
|
||||
'sharepoint_site_id','sharepoint_item_id','sharepoint_sheet_name'
|
||||
)`
|
||||
);
|
||||
const settings: Record<string, string> = {};
|
||||
for (const row of result.rows) settings[row.key] = row.value;
|
||||
// Indique juste si le secret est présent sans l'exposer
|
||||
const secretResult = await db.query(
|
||||
"SELECT value FROM app_settings WHERE key='graph_client_secret_enc'"
|
||||
);
|
||||
settings.has_secret = secretResult.rows[0] ? 'true' : 'false';
|
||||
res.json(settings);
|
||||
}));
|
||||
|
||||
/** PUT /api/settings/app — Sauvegarde la config Microsoft Graph + SharePoint */
|
||||
router.put('/app', validate(appSettingsSchema), wrap(async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
const {
|
||||
graph_tenant_id, graph_client_id, graph_client_secret,
|
||||
sharepoint_site_id, sharepoint_item_id, sharepoint_sheet_name,
|
||||
} = req.body;
|
||||
|
||||
const upsert = async (key: string, value: string | undefined) => {
|
||||
if (value === undefined || value === null || value === '') return;
|
||||
await db.query(
|
||||
`INSERT INTO app_settings (key, value, updated_at)
|
||||
VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value=$2, updated_at=NOW()`,
|
||||
[key, value]
|
||||
);
|
||||
};
|
||||
|
||||
await upsert('graph_tenant_id', graph_tenant_id);
|
||||
await upsert('graph_client_id', graph_client_id);
|
||||
await upsert('sharepoint_site_id', sharepoint_site_id);
|
||||
await upsert('sharepoint_item_id', sharepoint_item_id);
|
||||
await upsert('sharepoint_sheet_name', sharepoint_sheet_name);
|
||||
if (graph_client_secret) {
|
||||
await upsert('graph_client_secret_enc', encrypt(graph_client_secret));
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
}));
|
||||
|
||||
/** POST /api/settings/sharepoint/test — Vérifie la connexion Graph + accès au fichier Excel */
|
||||
router.post('/sharepoint/test', wrap(async (_req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { testSharepointConnection } = await import('../services/sharepoint');
|
||||
await testSharepointConnection();
|
||||
res.json({ success: true, message: 'Connexion SharePoint OK — fichier Excel accessible.' });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
}));
|
||||
|
||||
export default router;
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Script d'initialisation des utilisateurs Greg et Gaël.
|
||||
* À exécuter une seule fois après la migration.
|
||||
*
|
||||
* Variables d'environnement requises :
|
||||
* GREG_EMAIL, GREG_PASSWORD, GAEL_EMAIL, GAEL_PASSWORD
|
||||
*
|
||||
* Usage : npm run init-users
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { db } from '../db';
|
||||
|
||||
async function initUsers() {
|
||||
const SALT_ROUNDS = 12;
|
||||
|
||||
const users = [
|
||||
{
|
||||
name: 'Greg',
|
||||
email: process.env.GREG_EMAIL || 'greg@example.com',
|
||||
password: process.env.GREG_PASSWORD || 'changeme',
|
||||
},
|
||||
{
|
||||
name: 'Gaël',
|
||||
email: process.env.GAEL_EMAIL || 'gael@example.com',
|
||||
password: process.env.GAEL_PASSWORD || 'changeme',
|
||||
},
|
||||
];
|
||||
|
||||
for (const user of users) {
|
||||
const hash = await bcrypt.hash(user.password, SALT_ROUNDS);
|
||||
const result = await db.query(
|
||||
`INSERT INTO users (name, email, password_hash)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (email) DO UPDATE SET name=$1, updated_at=NOW()
|
||||
RETURNING id, name, email`,
|
||||
[user.name, user.email, hash]
|
||||
);
|
||||
console.log(`✅ Utilisateur créé/mis à jour : ${result.rows[0].name} <${result.rows[0].email}>`);
|
||||
}
|
||||
|
||||
await db.end();
|
||||
}
|
||||
|
||||
initUsers().catch((err) => {
|
||||
console.error('❌ Initialisation échouée :', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* Script de migration — crée toutes les tables et données initiales.
|
||||
* Usage : npm run migrate
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { db } from '../db';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
async function migrate() {
|
||||
const sqlPath = path.join(__dirname, '../migrations/001_init.sql');
|
||||
const sql = fs.readFileSync(sqlPath, 'utf8');
|
||||
|
||||
console.log('🔄 Exécution de la migration 001_init.sql...');
|
||||
await db.query(sql);
|
||||
console.log('✅ Migration terminée avec succès');
|
||||
|
||||
await db.end();
|
||||
}
|
||||
|
||||
migrate().catch((err) => {
|
||||
console.error('❌ Migration échouée :', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import { decrypt } from '../crypto';
|
||||
|
||||
export interface UserSmtpConfig {
|
||||
smtp_host: string;
|
||||
smtp_port: number;
|
||||
smtp_secure: boolean;
|
||||
smtp_user: string;
|
||||
smtp_pass_enc: string;
|
||||
smtp_from_name: string;
|
||||
smtp_from_email: string;
|
||||
}
|
||||
|
||||
function createTransporter(user: UserSmtpConfig) {
|
||||
return nodemailer.createTransport({
|
||||
host: user.smtp_host,
|
||||
port: user.smtp_port,
|
||||
secure: user.smtp_secure,
|
||||
auth: {
|
||||
user: user.smtp_user,
|
||||
pass: decrypt(user.smtp_pass_enc),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie la facture par email.
|
||||
* Expéditeur = compte SMTP de l'utilisateur connecté.
|
||||
* Destinataire = email de la société à rembourser.
|
||||
*/
|
||||
export async function sendInvoiceEmail(
|
||||
user: UserSmtpConfig,
|
||||
toEmail: string,
|
||||
pdfAbsPath: string,
|
||||
pdfFilename: string
|
||||
): Promise<void> {
|
||||
const transporter = createTransporter(user);
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${user.smtp_from_name}" <${user.smtp_from_email}>`,
|
||||
to: toEmail,
|
||||
subject: 'Facture à rembourser',
|
||||
text: '',
|
||||
attachments: [
|
||||
{
|
||||
filename: pdfFilename,
|
||||
path: pdfAbsPath,
|
||||
contentType: 'application/pdf',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Email de test pour vérifier la config SMTP de l'utilisateur.
|
||||
*/
|
||||
export async function sendTestEmail(user: UserSmtpConfig): Promise<void> {
|
||||
const transporter = createTransporter(user);
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${user.smtp_from_name}" <${user.smtp_from_email}>`,
|
||||
to: user.smtp_from_email,
|
||||
subject: '[NotesFrais] Test de configuration SMTP',
|
||||
text: 'Votre configuration SMTP fonctionne correctement ✓',
|
||||
});
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
// @ts-ignore
|
||||
const { PDFDocument, rgb, StandardFonts } = require('pdf-lib');
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
export interface PdfGuest {
|
||||
name: string;
|
||||
company?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère le PDF de la facture.
|
||||
* - Une page par image (ticket/reçu)
|
||||
* - Si des invités sont présents, la liste est ajoutée EN BAS de la dernière
|
||||
* page d'image (même page, sous le ticket), sans page séparée.
|
||||
* - userName : prénom affiché dans l'en-tête "Invité par …"
|
||||
*/
|
||||
export async function generateInvoicePdf(
|
||||
imagePaths: string[],
|
||||
guests: PdfGuest[],
|
||||
outputPath: string,
|
||||
userName = 'Moi'
|
||||
): Promise<void> {
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
|
||||
const font = guests.length > 0 ? await pdfDoc.embedFont(StandardFonts.Helvetica) : null;
|
||||
const fontBold = guests.length > 0 ? await pdfDoc.embedFont(StandardFonts.HelveticaBold) : null;
|
||||
|
||||
for (let idx = 0; idx < imagePaths.length; idx++) {
|
||||
const imgPath = imagePaths[idx];
|
||||
const imgBytes = await fs.readFile(imgPath);
|
||||
const ext = path.extname(imgPath).toLowerCase();
|
||||
const image = ext === '.png'
|
||||
? await pdfDoc.embedPng(imgBytes)
|
||||
: await pdfDoc.embedJpg(imgBytes);
|
||||
|
||||
// Normaliser à une largeur A4 (595pt) pour que la section invités
|
||||
// soit visible. Sans normalisation, image.width est en pixels
|
||||
// (ex. 3024px), ce qui donne une page de 42 pouces de large et
|
||||
// rend la section invités imperceptible en bas de page.
|
||||
const PAGE_W = 595;
|
||||
const scale = PAGE_W / image.width;
|
||||
const imgW = PAGE_W;
|
||||
const imgH = Math.round(image.height * scale);
|
||||
|
||||
const isLast = idx === imagePaths.length - 1;
|
||||
const addList = isLast && guests.length > 0;
|
||||
|
||||
// Hauteur supplémentaire pour la liste d'invités (sous le ticket)
|
||||
const PADDING = 24; // marge entre ticket et liste
|
||||
const LINE_H = 44; // hauteur par ligne d'invité (×2)
|
||||
const HEADER_H = 80; // titre + séparateur (×2)
|
||||
const listH = addList
|
||||
? HEADER_H + guests.length * LINE_H + PADDING * 2
|
||||
: 0;
|
||||
|
||||
const pageW = imgW;
|
||||
const pageH = imgH + listH;
|
||||
|
||||
const page = pdfDoc.addPage([pageW, pageH]);
|
||||
|
||||
// ── Image (placée en haut de la page) ──────────────────────
|
||||
page.drawImage(image, { x: 0, y: listH, width: imgW, height: imgH });
|
||||
|
||||
// ── Liste des invités (en bas, sous l'image) ───────────────
|
||||
if (addList && font && fontBold) {
|
||||
const M = 30; // marge gauche/droite
|
||||
let y = listH - PADDING;
|
||||
|
||||
// Ligne de séparation entre ticket et liste
|
||||
page.drawLine({
|
||||
start: { x: M, y: listH - 1 },
|
||||
end: { x: pageW - M, y: listH - 1 },
|
||||
thickness: 1,
|
||||
color: rgb(0.8, 0.8, 0.8),
|
||||
});
|
||||
|
||||
// Titre "Invité par <prénom>"
|
||||
page.drawText(`Invité par ${userName}`, {
|
||||
x: M, y,
|
||||
size: 26,
|
||||
font: fontBold,
|
||||
color: rgb(0.15, 0.15, 0.15),
|
||||
});
|
||||
y -= HEADER_H - PADDING - 10;
|
||||
|
||||
// Séparateur sous le titre
|
||||
page.drawLine({
|
||||
start: { x: M, y },
|
||||
end: { x: pageW - M, y },
|
||||
thickness: 0.5,
|
||||
color: rgb(0.75, 0.75, 0.75),
|
||||
});
|
||||
y -= 22;
|
||||
|
||||
// Lignes invités
|
||||
for (const guest of guests) {
|
||||
if (y < 4) break;
|
||||
page.drawText(guest.name, {
|
||||
x: M, y,
|
||||
size: 22,
|
||||
font: fontBold,
|
||||
color: rgb(0.1, 0.1, 0.1),
|
||||
maxWidth: guest.company ? (pageW - M * 2) / 2 - 10 : pageW - M * 2,
|
||||
});
|
||||
if (guest.company) {
|
||||
page.drawText(guest.company, {
|
||||
x: M + (pageW - M * 2) / 2,
|
||||
y,
|
||||
size: 22,
|
||||
font,
|
||||
color: rgb(0.35, 0.35, 0.35),
|
||||
maxWidth: (pageW - M * 2) / 2,
|
||||
});
|
||||
}
|
||||
y -= LINE_H;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
await fs.writeFile(outputPath, pdfBytes);
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
import { db } from '../db';
|
||||
import { decrypt } from '../crypto';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────
|
||||
|
||||
interface GraphConfig {
|
||||
tenantId: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
sharepointSiteId: string;
|
||||
sharepointItemId: string;
|
||||
sharepointSheet: string;
|
||||
}
|
||||
|
||||
export interface SharepointRowData {
|
||||
category: string;
|
||||
companyName: string;
|
||||
comment: string;
|
||||
guests: Array<{ name: string; company?: string | null }>;
|
||||
date: string; // format JJ/MM/AAAA
|
||||
amount: number;
|
||||
userName: string; // 'Greg' ou 'Gaël'
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
async function getGraphConfig(): Promise<GraphConfig> {
|
||||
const result = await db.query(
|
||||
`SELECT key, value FROM app_settings
|
||||
WHERE key IN (
|
||||
'graph_tenant_id', 'graph_client_id', 'graph_client_secret_enc',
|
||||
'sharepoint_site_id', 'sharepoint_item_id', 'sharepoint_sheet_name'
|
||||
)`
|
||||
);
|
||||
const cfg: Record<string, string> = {};
|
||||
for (const row of result.rows) cfg[row.key] = row.value;
|
||||
|
||||
if (!cfg.graph_tenant_id || !cfg.graph_client_id || !cfg.graph_client_secret_enc) {
|
||||
throw new Error('Microsoft Graph non configuré (tenant_id, client_id ou client_secret manquant)');
|
||||
}
|
||||
if (!cfg.sharepoint_site_id || !cfg.sharepoint_item_id) {
|
||||
throw new Error('Fichier Excel SharePoint non configuré (site_id ou item_id manquant dans Paramètres → Microsoft 365)');
|
||||
}
|
||||
|
||||
return {
|
||||
tenantId: cfg.graph_tenant_id,
|
||||
clientId: cfg.graph_client_id,
|
||||
clientSecret: decrypt(cfg.graph_client_secret_enc),
|
||||
sharepointSiteId: cfg.sharepoint_site_id,
|
||||
sharepointItemId: cfg.sharepoint_item_id,
|
||||
sharepointSheet: cfg.sharepoint_sheet_name ?? 'Feuil1',
|
||||
};
|
||||
}
|
||||
|
||||
async function getAccessToken(cfg: GraphConfig): Promise<string> {
|
||||
const response = await fetch(
|
||||
`https://login.microsoftonline.com/${cfg.tenantId}/oauth2/v2.0/token`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: cfg.clientId,
|
||||
client_secret: cfg.clientSecret,
|
||||
scope: 'https://graph.microsoft.com/.default',
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`Obtention token Graph échouée (${response.status}) : ${body}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { access_token: string };
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
// ─── Test de connexion (sans écriture) ───────────────────────
|
||||
|
||||
/**
|
||||
* Vérifie que le token Graph s'obtient et que la feuille Excel est accessible.
|
||||
* Utilisé par POST /api/settings/sharepoint/test.
|
||||
*/
|
||||
export async function testSharepointConnection(): Promise<void> {
|
||||
const cfg = await getGraphConfig();
|
||||
const token = await getAccessToken(cfg);
|
||||
|
||||
const baseUrl = `https://graph.microsoft.com/v1.0/sites/${cfg.sharepointSiteId}/drive/items/${cfg.sharepointItemId}/workbook/worksheets/${encodeURIComponent(cfg.sharepointSheet)}`;
|
||||
const resp = await fetch(`${baseUrl}/usedRange?$select=rowCount`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text();
|
||||
throw new Error(`Impossible d'accéder à la feuille "${cfg.sharepointSheet}" (${resp.status}) : ${body.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fonction principale ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ajoute une ligne dans le fichier Excel SharePoint commun selon le mapping :
|
||||
* A : Catégorie
|
||||
* B : Société facturée
|
||||
* C : Commentaire + invités
|
||||
* D : Date (JJ/MM/AAAA)
|
||||
* E : Montant si Greg
|
||||
* F : Montant si Gaël
|
||||
*/
|
||||
export async function addRowToExcel(row: SharepointRowData): Promise<void> {
|
||||
const cfg = await getGraphConfig();
|
||||
const token = await getAccessToken(cfg);
|
||||
|
||||
// Construction de la cellule C (commentaire + invités)
|
||||
let cellComment = row.comment || '';
|
||||
if (row.guests.length > 0) {
|
||||
const guestStr = row.guests
|
||||
.map((g) => (g.company ? `${g.name} — ${g.company}` : g.name))
|
||||
.join(' ; ');
|
||||
cellComment = cellComment
|
||||
? `${cellComment}. Invités : ${guestStr}`
|
||||
: `Invités : ${guestStr}`;
|
||||
}
|
||||
|
||||
// Colonnes E/F selon l'utilisateur
|
||||
const nameLower = row.userName.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
|
||||
const isGreg = nameLower === 'greg';
|
||||
const isGael = nameLower === 'gael';
|
||||
|
||||
const values = [[
|
||||
row.category, // A
|
||||
row.companyName, // B
|
||||
cellComment, // C
|
||||
row.date, // D
|
||||
isGreg ? row.amount : null, // E — Greg
|
||||
isGael ? row.amount : null, // F — Gaël
|
||||
]];
|
||||
|
||||
// ── Trouver la première ligne vide ───────────────────────────
|
||||
const baseUrl = `https://graph.microsoft.com/v1.0/sites/${cfg.sharepointSiteId}/drive/items/${cfg.sharepointItemId}/workbook/worksheets/${encodeURIComponent(cfg.sharepointSheet)}`;
|
||||
|
||||
const rangeResp = await fetch(`${baseUrl}/usedRange`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!rangeResp.ok) {
|
||||
const body = await rangeResp.text();
|
||||
throw new Error(`Graph usedRange échoué (${rangeResp.status}) : ${body}`);
|
||||
}
|
||||
|
||||
const rangeData = (await rangeResp.json()) as { rowCount: number };
|
||||
const nextRow = rangeData.rowCount + 1;
|
||||
|
||||
// ── Écriture de la nouvelle ligne ────────────────────────────
|
||||
const writeResp = await fetch(
|
||||
`${baseUrl}/range(address='A${nextRow}:F${nextRow}')`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ values }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!writeResp.ok) {
|
||||
const body = await writeResp.text();
|
||||
throw new Error(`Graph écriture ligne échouée (${writeResp.status}) : ${body}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user