feat: ShootTracker SQLite+JWT+YOLOv8

This commit is contained in:
ShootTracker Deploy
2026-04-30 22:44:27 +02:00
commit 2578cb6ec2
55 changed files with 5759 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
PORT=3001
NODE_ENV=production
# Supabase
SUPABASE_URL=https://xxxxxxxxxxxxxxxxxxxx.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
# Microservice IA
AI_SERVICE_URL=http://shoottracker-ai:8000
# CORS
CORS_ORIGIN=https://shoottracker.domench.fr
+39
View File
@@ -0,0 +1,39 @@
{
"name": "shoottracker-backend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"axios": "^1.7.2",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^9.6.0",
"cors": "^2.8.5",
"date-fns": "^3.6.0",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.31.2",
"express": "^4.19.2",
"jspdf": "^2.5.1",
"jspdf-autotable": "^3.8.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"uuid": "^10.0.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.10",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/multer": "^1.4.11",
"@types/node": "^20.14.2",
"@types/uuid": "^10.0.0",
"drizzle-kit": "^0.22.7",
"ts-node-dev": "^2.0.0",
"typescript": "^5.4.5"
}
}
+138
View File
@@ -0,0 +1,138 @@
import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import * as schema from './schema'
import path from 'path'
import fs from 'fs'
const DB_PATH = process.env.DATABASE_PATH || path.join(process.cwd(), 'data', 'shoottracker.db')
// Assurer que le répertoire existe
const dbDir = path.dirname(DB_PATH)
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true })
}
const sqlite = new Database(DB_PATH)
sqlite.pragma('journal_mode = WAL')
sqlite.pragma('foreign_keys = ON')
export const db = drizzle(sqlite, { schema })
// ─── Migration auto au démarrage ──────────────────────────────────────────────
export function runMigrations() {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
first_name TEXT DEFAULT '',
last_name TEXT DEFAULT '',
club TEXT DEFAULT '',
disciplines TEXT DEFAULT '[]',
avatar_url TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS weapons (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
nickname TEXT,
type TEXT NOT NULL,
caliber TEXT,
brand TEXT,
model TEXT,
serial_number TEXT,
photo_url TEXT,
notes TEXT,
is_archived INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS training_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
weapon_id TEXT REFERENCES weapons(id) ON DELETE SET NULL,
session_date TEXT NOT NULL,
location TEXT,
target_type TEXT NOT NULL,
distance_m INTEGER,
notes_start TEXT,
notes_end TEXT,
total_shots_declared INTEGER DEFAULT 0,
total_shots_detected INTEGER DEFAULT 0,
total_score INTEGER DEFAULT 0,
avg_score REAL DEFAULT 0,
best_series_score INTEGER DEFAULT 0,
ai_detection_rate INTEGER DEFAULT 0,
avg_dispersion REAL,
is_completed INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS series (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES training_sessions(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
series_number INTEGER NOT NULL,
shots_declared INTEGER DEFAULT 0,
shots_detected INTEGER DEFAULT 0,
shots_manual INTEGER DEFAULT 0,
photo_url TEXT,
annotated_photo_url TEXT,
score_zone INTEGER DEFAULT 0,
score_groupement INTEGER DEFAULT 0,
score_total INTEGER DEFAULT 0,
dispersion_radius REAL,
center_x REAL,
center_y REAL,
ai_data TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS impacts (
id TEXT PRIMARY KEY,
series_id TEXT NOT NULL REFERENCES series(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
x REAL NOT NULL,
y REAL NOT NULL,
zone INTEGER,
points INTEGER DEFAULT 0,
is_manual INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS invitations (
id TEXT PRIMARY KEY,
inviter_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
email TEXT,
token TEXT NOT NULL UNIQUE,
is_used INTEGER DEFAULT 0,
used_by TEXT,
created_at TEXT DEFAULT (datetime('now')),
expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_locations (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
used_count INTEGER DEFAULT 1,
last_used_at TEXT DEFAULT (datetime('now')),
UNIQUE(user_id, name)
);
CREATE INDEX IF NOT EXISTS idx_weapons_user ON weapons(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON training_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_date ON training_sessions(session_date DESC);
CREATE INDEX IF NOT EXISTS idx_series_session ON series(session_id);
CREATE INDEX IF NOT EXISTS idx_impacts_series ON impacts(series_id);
CREATE INDEX IF NOT EXISTS idx_invitations_token ON invitations(token);
`)
console.log('✓ Migrations SQLite exécutées')
}
export { sqlite }
+112
View File
@@ -0,0 +1,112 @@
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core'
import { sql } from 'drizzle-orm'
// ─── Users ────────────────────────────────────────────────────────────────────
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
password_hash: text('password_hash').notNull(),
first_name: text('first_name').default(''),
last_name: text('last_name').default(''),
club: text('club').default(''),
disciplines: text('disciplines').default('[]'), // JSON array string
avatar_url: text('avatar_url'),
created_at: text('created_at').default(sql`(datetime('now'))`),
updated_at: text('updated_at').default(sql`(datetime('now'))`),
})
// ─── Weapons ──────────────────────────────────────────────────────────────────
export const weapons = sqliteTable('weapons', {
id: text('id').primaryKey(),
user_id: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
nickname: text('nickname'),
type: text('type').notNull(), // pistolet|carabine|fusil|arc|arbalète|autre
caliber: text('caliber'),
brand: text('brand'),
model: text('model'),
serial_number: text('serial_number'),
photo_url: text('photo_url'),
notes: text('notes'),
is_archived: integer('is_archived', { mode: 'boolean' }).default(false),
created_at: text('created_at').default(sql`(datetime('now'))`),
updated_at: text('updated_at').default(sql`(datetime('now'))`),
})
// ─── Training sessions ────────────────────────────────────────────────────────
export const training_sessions = sqliteTable('training_sessions', {
id: text('id').primaryKey(),
user_id: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
weapon_id: text('weapon_id').references(() => weapons.id, { onDelete: 'set null' }),
session_date: text('session_date').notNull(),
location: text('location'),
target_type: text('target_type').notNull(), // issf|silhouette|libre
distance_m: integer('distance_m'),
notes_start: text('notes_start'),
notes_end: text('notes_end'),
total_shots_declared: integer('total_shots_declared').default(0),
total_shots_detected: integer('total_shots_detected').default(0),
total_score: integer('total_score').default(0),
avg_score: real('avg_score').default(0),
best_series_score: integer('best_series_score').default(0),
ai_detection_rate: integer('ai_detection_rate').default(0),
avg_dispersion: real('avg_dispersion'),
is_completed: integer('is_completed', { mode: 'boolean' }).default(false),
created_at: text('created_at').default(sql`(datetime('now'))`),
updated_at: text('updated_at').default(sql`(datetime('now'))`),
})
// ─── Series ───────────────────────────────────────────────────────────────────
export const series = sqliteTable('series', {
id: text('id').primaryKey(),
session_id: text('session_id').notNull().references(() => training_sessions.id, { onDelete: 'cascade' }),
user_id: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
series_number: integer('series_number').notNull(),
shots_declared: integer('shots_declared').notNull().default(0),
shots_detected: integer('shots_detected').default(0),
shots_manual: integer('shots_manual').default(0),
photo_url: text('photo_url'),
annotated_photo_url: text('annotated_photo_url'),
score_zone: integer('score_zone').default(0),
score_groupement: integer('score_groupement').default(0),
score_total: integer('score_total').default(0),
dispersion_radius: real('dispersion_radius'),
center_x: real('center_x'),
center_y: real('center_y'),
ai_data: text('ai_data'), // JSON string
created_at: text('created_at').default(sql`(datetime('now'))`),
})
// ─── Impacts ──────────────────────────────────────────────────────────────────
export const impacts = sqliteTable('impacts', {
id: text('id').primaryKey(),
series_id: text('series_id').notNull().references(() => series.id, { onDelete: 'cascade' }),
user_id: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
x: real('x').notNull(),
y: real('y').notNull(),
zone: integer('zone'),
points: integer('points').default(0),
is_manual: integer('is_manual', { mode: 'boolean' }).default(false),
created_at: text('created_at').default(sql`(datetime('now'))`),
})
// ─── Invitations ──────────────────────────────────────────────────────────────
export const invitations = sqliteTable('invitations', {
id: text('id').primaryKey(),
inviter_id: text('inviter_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
email: text('email'),
token: text('token').notNull().unique(),
is_used: integer('is_used', { mode: 'boolean' }).default(false),
used_by: text('used_by'),
created_at: text('created_at').default(sql`(datetime('now'))`),
expires_at: text('expires_at').notNull(),
})
// ─── User locations ───────────────────────────────────────────────────────────
export const user_locations = sqliteTable('user_locations', {
id: text('id').primaryKey(),
user_id: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
used_count: integer('used_count').default(1),
last_used_at: text('last_used_at').default(sql`(datetime('now'))`),
})
+58
View File
@@ -0,0 +1,58 @@
import 'dotenv/config'
import express from 'express'
import cors from 'cors'
import path from 'path'
import fs from 'fs'
import { runMigrations } from './db'
import { authRouter } from './routes/auth'
import { weaponsRouter } from './routes/weapons'
import { sessionsRouter } from './routes/sessions'
import { seriesRouter } from './routes/series'
import { miscRouter } from './routes/misc'
import { analyzeRouter } from './routes/analyze'
import { exportRouter } from './routes/export'
const app = express()
const PORT = parseInt(process.env.PORT || '3001')
const UPLOADS_DIR = process.env.UPLOADS_DIR || path.join(process.cwd(), 'data', 'uploads')
// ─── Migrations ──────────────────────────────────────────────────────────────
runMigrations()
// ─── Dossiers ─────────────────────────────────────────────────────────────────
fs.mkdirSync(UPLOADS_DIR, { recursive: true })
// ─── Middlewares ──────────────────────────────────────────────────────────────
app.use(cors({ origin: process.env.CORS_ORIGIN || '*', credentials: true }))
app.use(express.json({ limit: '50mb' }))
app.use(express.urlencoded({ extended: true, limit: '50mb' }))
// ─── Fichiers statiques (uploads) ─────────────────────────────────────────────
app.use('/uploads', express.static(UPLOADS_DIR))
// ─── Routes API ───────────────────────────────────────────────────────────────
app.use('/api/auth', authRouter)
app.use('/api/weapons', weaponsRouter)
app.use('/api', sessionsRouter)
app.use('/api', seriesRouter)
app.use('/api', miscRouter)
app.use('/api', analyzeRouter)
app.use('/api/export', exportRouter)
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', service: 'shoottracker', ts: new Date().toISOString() })
})
// ─── Serve Frontend (production) ─────────────────────────────────────────────
const publicDir = path.join(__dirname, '..', 'public')
if (fs.existsSync(publicDir)) {
app.use(express.static(publicDir))
app.get('*', (_req, res) => res.sendFile(path.join(publicDir, 'index.html')))
}
// ─── Start ────────────────────────────────────────────────────────────────────
app.listen(PORT, () => {
console.log(`✓ ShootTracker backend → http://localhost:${PORT}`)
console.log(` Uploads : ${UPLOADS_DIR}`)
console.log(` AI : ${process.env.AI_SERVICE_URL || 'http://localhost:8000'}`)
})
+29
View File
@@ -0,0 +1,29 @@
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
export interface AuthRequest extends Request {
userId?: string
userEmail?: string
}
const JWT_SECRET = process.env.JWT_SECRET || 'shoottracker-dev-secret-change-in-prod'
export function requireAuth(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Token manquant' })
}
const token = authHeader.slice(7)
try {
const payload = jwt.verify(token, JWT_SECRET) as { userId: string; email: string }
req.userId = payload.userId
req.userEmail = payload.email
next()
} catch {
return res.status(401).json({ error: 'Token invalide ou expiré' })
}
}
export function signToken(userId: string, email: string): string {
return jwt.sign({ userId, email }, JWT_SECRET, { expiresIn: '30d' })
}
+49
View File
@@ -0,0 +1,49 @@
import { Router } from 'express'
import axios from 'axios'
export const analyzeRouter = Router()
const AI_SERVICE_URL = process.env.AI_SERVICE_URL || 'http://localhost:8000'
/**
* POST /api/analyze
* Proxy vers le microservice Python IA
* Body: { imageBase64: string, targetType: string, previousImageBase64?: string }
*/
analyzeRouter.post('/analyze', async (req, res) => {
try {
const { imageBase64, targetType, previousImageBase64 } = req.body
if (!imageBase64) {
return res.status(400).json({ error: 'Image manquante' })
}
if (!targetType || !['issf', 'silhouette', 'libre'].includes(targetType)) {
return res.status(400).json({ error: 'Type de cible invalide' })
}
const payload = {
image_base64: imageBase64,
target_type: targetType,
previous_image_base64: previousImageBase64 || null,
}
const response = await axios.post(`${AI_SERVICE_URL}/analyze`, payload, {
timeout: 60000, // 60s pour le traitement IA
maxBodyLength: Infinity,
maxContentLength: Infinity,
})
return res.json(response.data)
} catch (error: any) {
console.error('AI service error:', error.message)
if (axios.isAxiosError(error)) {
if (error.code === 'ECONNREFUSED') {
return res.status(503).json({ error: 'Service IA indisponible. Veuillez réessayer.' })
}
if (error.response) {
return res.status(error.response.status).json(error.response.data)
}
}
return res.status(500).json({ error: 'Erreur lors de l\'analyse IA' })
}
})
+128
View File
@@ -0,0 +1,128 @@
import { Router } from 'express'
import bcrypt from 'bcryptjs'
import { v4 as uuid } from 'uuid'
import { eq } from 'drizzle-orm'
import { db } from '../db'
import { users, invitations } from '../db/schema'
import { requireAuth, signToken, AuthRequest } from '../middleware/auth'
export const authRouter = Router()
// ─── Inscription ──────────────────────────────────────────────────────────────
authRouter.post('/register', async (req, res) => {
const { email, password, first_name, last_name, club, invite_token } = req.body
if (!email || !password) return res.status(400).json({ error: 'Email et mot de passe requis' })
if (password.length < 8) return res.status(400).json({ error: 'Mot de passe minimum 8 caractères' })
try {
// Vérifier l'email unique
const existing = db.select().from(users).where(eq(users.email, email.toLowerCase())).get()
if (existing) return res.status(409).json({ error: 'Email déjà utilisé' })
// Vérifier l'invitation si fournie
if (invite_token) {
const inv = db.select().from(invitations)
.where(eq(invitations.token, invite_token)).get()
if (!inv || inv.is_used || new Date(inv.expires_at) < new Date()) {
return res.status(400).json({ error: 'Invitation invalide ou expirée' })
}
}
const id = uuid()
const password_hash = await bcrypt.hash(password, 12)
db.insert(users).values({
id, email: email.toLowerCase(),
password_hash,
first_name: first_name || '',
last_name: last_name || '',
club: club || '',
disciplines: '[]',
}).run()
// Marquer l'invitation comme utilisée
if (invite_token) {
db.update(invitations).set({ is_used: true, used_by: id })
.where(eq(invitations.token, invite_token)).run()
}
const token = signToken(id, email.toLowerCase())
const user = db.select().from(users).where(eq(users.id, id)).get()
return res.status(201).json({ token, user: sanitizeUser(user!) })
} catch (e: any) {
console.error('Register error:', e)
return res.status(500).json({ error: 'Erreur serveur' })
}
})
// ─── Connexion ────────────────────────────────────────────────────────────────
authRouter.post('/login', async (req, res) => {
const { email, password } = req.body
if (!email || !password) return res.status(400).json({ error: 'Email et mot de passe requis' })
try {
const user = db.select().from(users).where(eq(users.email, email.toLowerCase())).get()
if (!user) return res.status(401).json({ error: 'Email ou mot de passe incorrect' })
const ok = await bcrypt.compare(password, user.password_hash)
if (!ok) return res.status(401).json({ error: 'Email ou mot de passe incorrect' })
const token = signToken(user.id, user.email)
return res.json({ token, user: sanitizeUser(user) })
} catch (e) {
return res.status(500).json({ error: 'Erreur serveur' })
}
})
// ─── Profil courant ───────────────────────────────────────────────────────────
authRouter.get('/me', requireAuth, (req: AuthRequest, res) => {
const user = db.select().from(users).where(eq(users.id, req.userId!)).get()
if (!user) return res.status(404).json({ error: 'Utilisateur introuvable' })
return res.json(sanitizeUser(user))
})
// ─── Mise à jour profil ───────────────────────────────────────────────────────
authRouter.put('/profile', requireAuth, async (req: AuthRequest, res) => {
const { first_name, last_name, club, disciplines } = req.body
try {
db.update(users).set({
first_name: first_name ?? '',
last_name: last_name ?? '',
club: club ?? '',
disciplines: Array.isArray(disciplines) ? JSON.stringify(disciplines) : (disciplines || '[]'),
updated_at: new Date().toISOString(),
}).where(eq(users.id, req.userId!)).run()
const updated = db.select().from(users).where(eq(users.id, req.userId!)).get()
return res.json(sanitizeUser(updated!))
} catch (e) {
return res.status(500).json({ error: 'Erreur mise à jour profil' })
}
})
// ─── Changer le mot de passe ──────────────────────────────────────────────────
authRouter.put('/password', requireAuth, async (req: AuthRequest, res) => {
const { current_password, new_password } = req.body
if (!current_password || !new_password) return res.status(400).json({ error: 'Champs requis' })
if (new_password.length < 8) return res.status(400).json({ error: 'Minimum 8 caractères' })
const user = db.select().from(users).where(eq(users.id, req.userId!)).get()
if (!user) return res.status(404).json({ error: 'Utilisateur introuvable' })
const ok = await bcrypt.compare(current_password, user.password_hash)
if (!ok) return res.status(401).json({ error: 'Mot de passe actuel incorrect' })
const hash = await bcrypt.hash(new_password, 12)
db.update(users).set({ password_hash: hash }).where(eq(users.id, req.userId!)).run()
return res.json({ message: 'Mot de passe mis à jour' })
})
// ─── Helpers ──────────────────────────────────────────────────────────────────
function sanitizeUser(u: typeof users.$inferSelect) {
const { password_hash, ...safe } = u
return {
...safe,
disciplines: safeJson(safe.disciplines, []),
}
}
function safeJson(str: string | null, fallback: any) {
try { return JSON.parse(str || '[]') } catch { return fallback }
}
+171
View File
@@ -0,0 +1,171 @@
import { Router } from 'express'
import { eq, and, gte } from 'drizzle-orm'
import { db } from '../db'
import { training_sessions, weapons, series } from '../db/schema'
import { requireAuth, AuthRequest } from '../middleware/auth'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
import jsPDF from 'jspdf'
import 'jspdf-autotable'
import * as XLSX from 'xlsx'
export const exportRouter = Router()
exportRouter.use(requireAuth)
// ─── Export PDF d'une séance ──────────────────────────────────────────────────
exportRouter.get('/pdf/:sessionId', async (req: AuthRequest, res) => {
const sess = db.select().from(training_sessions)
.where(and(eq(training_sessions.id, req.params.sessionId), eq(training_sessions.user_id, req.userId!)))
.get()
if (!sess) return res.status(404).json({ error: 'Séance introuvable' })
const weapon = sess.weapon_id ? db.select().from(weapons).where(eq(weapons.id, sess.weapon_id)).get() : null
const seriesList = db.select().from(series).where(eq(series.session_id, sess.id)).all()
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' })
// En-tête
doc.setFillColor(13, 13, 13)
doc.rect(0, 0, 210, 35, 'F')
doc.setTextColor(220, 38, 38)
doc.setFontSize(22); doc.setFont('helvetica', 'bold')
doc.text('SHOOTTRACKER', 14, 15)
doc.setTextColor(180, 180, 180); doc.setFontSize(10); doc.setFont('helvetica', 'normal')
doc.text('Rapport de séance d\'entraînement', 14, 22)
doc.text(`Généré le ${format(new Date(), 'dd/MM/yyyy à HH:mm', { locale: fr })}`, 14, 28)
// Infos séance
doc.setTextColor(30, 30, 30); doc.setFontSize(14); doc.setFont('helvetica', 'bold')
doc.text('Informations de séance', 14, 48)
;(doc as any).autoTable({
startY: 52,
body: [
['Date', format(new Date(sess.session_date), "dd MMMM yyyy 'à' HH:mm", { locale: fr })],
['Lieu', sess.location || 'Non précisé'],
['Arme', weapon ? `${weapon.name}${weapon.brand ? ' — ' + weapon.brand : ''}${weapon.model ? ' ' + weapon.model : ''}` : 'Non précisé'],
['Calibre', weapon?.caliber || 'Non précisé'],
['Type de cible', sess.target_type.toUpperCase()],
['Distance', sess.distance_m ? `${sess.distance_m} mètres` : 'Non précisé'],
],
theme: 'striped', styles: { fontSize: 10, cellPadding: 3 },
columnStyles: { 0: { fontStyle: 'bold', cellWidth: 40 } },
})
// Résumé
let y = (doc as any).lastAutoTable.finalY + 10
doc.setFontSize(14); doc.setFont('helvetica', 'bold'); doc.text('Résumé', 14, y)
;(doc as any).autoTable({
startY: y + 4,
body: [
['Séries', String(seriesList.length)],
['Tirs déclarés', String(sess.total_shots_declared)],
['Tirs détectés IA', `${sess.total_shots_detected} (${sess.ai_detection_rate}%)`],
['Score total', String(sess.total_score)],
['Score moyen/série', String(sess.avg_score)],
['Meilleure série', String(sess.best_series_score)],
],
theme: 'grid', styles: { fontSize: 10, cellPadding: 3 },
columnStyles: { 0: { fontStyle: 'bold', cellWidth: 70 }, 1: { textColor: [220, 38, 38], fontStyle: 'bold' } },
})
// Tableau des séries
y = (doc as any).lastAutoTable.finalY + 10
if (y > 240) { doc.addPage(); y = 20 }
doc.setFontSize(14); doc.setFont('helvetica', 'bold'); doc.text('Détail des séries', 14, y)
if (seriesList.length > 0) {
;(doc as any).autoTable({
startY: y + 4,
head: [['Série', 'Déclarés', 'Détectés', 'Manuels', 'Zones', 'Groupement', 'Total', 'Dispersion']],
body: seriesList.map((s: any) => [
s.series_number, s.shots_declared, s.shots_detected, s.shots_manual || '—',
s.score_zone, s.score_groupement, s.score_total,
s.dispersion_radius ? Number(s.dispersion_radius).toFixed(1) : '—',
]),
theme: 'striped', styles: { fontSize: 9, cellPadding: 2.5 },
headStyles: { fillColor: [220, 38, 38], textColor: [255, 255, 255], fontStyle: 'bold' },
})
}
// Notes
if (sess.notes_start || sess.notes_end) {
y = (doc as any).lastAutoTable.finalY + 10
if (y > 240) { doc.addPage(); y = 20 }
doc.setFontSize(14); doc.setFont('helvetica', 'bold'); doc.text('Notes', 14, y); y += 6
doc.setFontSize(10); doc.setFont('helvetica', 'normal')
if (sess.notes_start) {
doc.setFont('helvetica', 'bold'); doc.text('Début :', 14, y); y += 5
doc.setFont('helvetica', 'normal')
const lines = doc.splitTextToSize(sess.notes_start, 180)
doc.text(lines, 14, y); y += lines.length * 5 + 3
}
if (sess.notes_end) {
doc.setFont('helvetica', 'bold'); doc.text('Fin :', 14, y); y += 5
doc.setFont('helvetica', 'normal')
const lines = doc.splitTextToSize(sess.notes_end, 180)
doc.text(lines, 14, y)
}
}
// Footer
const pc = doc.getNumberOfPages()
for (let i = 1; i <= pc; i++) {
doc.setPage(i); doc.setFontSize(8); doc.setTextColor(150)
doc.text(`ShootTracker — Page ${i}/${pc}`, 105, 290, { align: 'center' })
}
const filename = `shoottracker_${format(new Date(sess.session_date), 'yyyy-MM-dd')}.pdf`
res.setHeader('Content-Type', 'application/pdf')
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
res.send(Buffer.from(doc.output('arraybuffer')))
})
// ─── Export Excel progression ─────────────────────────────────────────────────
exportRouter.get('/excel', (req: AuthRequest, res) => {
const { period, weaponId } = req.query as { period?: string; weaponId?: string }
let query = db.select().from(training_sessions)
.$dynamic()
const filters = [eq(training_sessions.user_id, req.userId!), eq(training_sessions.is_completed, true)]
const now = new Date()
if (period === '30j') filters.push(gte(training_sessions.session_date, new Date(now.getTime() - 30*86400000).toISOString()))
if (period === '90j') filters.push(gte(training_sessions.session_date, new Date(now.getTime() - 90*86400000).toISOString()))
if (period === '1an') filters.push(gte(training_sessions.session_date, new Date(now.getTime() - 365*86400000).toISOString()))
const sessions = db.select().from(training_sessions)
.where(and(...filters)).all()
const weaponMap: Record<string, any> = {}
sessions.forEach(s => {
if (s.weapon_id && !weaponMap[s.weapon_id]) {
const w = db.select().from(weapons).where(eq(weapons.id, s.weapon_id)).get()
if (w) weaponMap[w.id] = w
}
})
const rows = sessions
.filter(s => !weaponId || weaponId === 'all' || s.weapon_id === weaponId)
.map(s => ({
'Date': format(new Date(s.session_date), 'dd/MM/yyyy HH:mm'),
'Lieu': s.location || '',
'Arme': weaponMap[s.weapon_id || '']?.name || '',
'Distance (m)': s.distance_m || '',
'Type cible': s.target_type,
'Tirs déclarés': s.total_shots_declared,
'Tirs détectés IA': s.total_shots_detected,
'Taux détection (%)': s.ai_detection_rate,
'Score total': s.total_score,
'Score moyen/série': s.avg_score,
'Meilleure série': s.best_series_score,
'Dispersion moy.': s.avg_dispersion || '',
}))
const ws = XLSX.utils.json_to_sheet(rows)
ws['!cols'] = Array(12).fill({ wch: 18 })
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, 'Séances')
const filename = `shoottracker_${format(new Date(), 'yyyy-MM-dd')}.xlsx`
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
res.send(XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }))
})
+88
View File
@@ -0,0 +1,88 @@
import { Router } from 'express'
import { eq, and, desc } from 'drizzle-orm'
import { v4 as uuid } from 'uuid'
import crypto from 'crypto'
import path from 'path'
import fs from 'fs'
import multer from 'multer'
import { db } from '../db'
import { invitations, user_locations, users } from '../db/schema'
import { requireAuth, AuthRequest } from '../middleware/auth'
export const miscRouter = Router()
// ─── Lieux mémorisés ──────────────────────────────────────────────────────────
miscRouter.get('/locations', requireAuth, (req: AuthRequest, res) => {
const rows = db.select().from(user_locations)
.where(eq(user_locations.user_id, req.userId!))
.orderBy(desc(user_locations.last_used_at))
.all()
return res.json(rows)
})
// ─── Invitations ──────────────────────────────────────────────────────────────
miscRouter.get('/invitations', requireAuth, (req: AuthRequest, res) => {
const rows = db.select().from(invitations)
.where(eq(invitations.inviter_id, req.userId!))
.orderBy(desc(invitations.created_at))
.all()
return res.json(rows)
})
miscRouter.post('/invitations', requireAuth, (req: AuthRequest, res) => {
const active = db.select().from(invitations)
.where(and(eq(invitations.inviter_id, req.userId!), eq(invitations.is_used, false)))
.all()
if (active.length >= 5) {
return res.status(400).json({ error: 'Maximum 5 invitations actives' })
}
const token = crypto.randomBytes(32).toString('hex')
const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()
const id = uuid()
db.insert(invitations).values({
id, inviter_id: req.userId!, token,
email: req.body.email || null,
expires_at: expires,
}).run()
const inv = db.select().from(invitations).where(eq(invitations.id, id)).get()
return res.status(201).json(inv)
})
miscRouter.delete('/invitations/:id', requireAuth, (req: AuthRequest, res) => {
db.delete(invitations)
.where(and(eq(invitations.id, req.params.id), eq(invitations.inviter_id, req.userId!)))
.run()
return res.json({ message: 'Invitation supprimée' })
})
// Vérification publique d'un token d'invitation
miscRouter.get('/invitations/validate/:token', (req, res) => {
const inv = db.select().from(invitations)
.where(eq(invitations.token, req.params.token)).get()
if (!inv || inv.is_used || new Date(inv.expires_at) < new Date()) {
return res.json({ valid: false, reason: inv?.is_used ? 'used' : 'invalid' })
}
return res.json({ valid: true, expires_at: inv.expires_at })
})
// ─── Upload avatar ────────────────────────────────────────────────────────────
const avatarStorage = multer.diskStorage({
destination: (req: any, _file, cb) => {
const dir = path.join(process.env.UPLOADS_DIR || './data/uploads', req.userId!, 'avatar')
fs.mkdirSync(dir, { recursive: true })
cb(null, dir)
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname)
cb(null, `avatar_${Date.now()}${ext}`)
},
})
const avatarUpload = multer({ storage: avatarStorage, limits: { fileSize: 5 * 1024 * 1024 } })
miscRouter.post('/auth/avatar', requireAuth, avatarUpload.single('avatar'), (req: AuthRequest, res) => {
if (!req.file) return res.status(400).json({ error: 'Fichier manquant' })
const relPath = `/uploads/${req.userId}/avatar/${req.file.filename}`
db.update(users).set({ avatar_url: relPath, updated_at: new Date().toISOString() })
.where(eq(users.id, req.userId!)).run()
return res.json({ avatar_url: relPath })
})
+133
View File
@@ -0,0 +1,133 @@
import { Router } from 'express'
import { eq, and } from 'drizzle-orm'
import { v4 as uuid } from 'uuid'
import path from 'path'
import fs from 'fs'
import multer from 'multer'
import { db } from '../db'
import { series, impacts, training_sessions } from '../db/schema'
import { requireAuth, AuthRequest } from '../middleware/auth'
export const seriesRouter = Router()
seriesRouter.use(requireAuth)
// ─── Config multer (photos cibles) ───────────────────────────────────────────
const storage = multer.diskStorage({
destination: (req: any, _file, cb) => {
const dir = path.join(process.env.UPLOADS_DIR || './data/uploads', req.userId!, 'targets')
fs.mkdirSync(dir, { recursive: true })
cb(null, dir)
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname) || '.jpg'
cb(null, `target_${Date.now()}${ext}`)
},
})
const upload = multer({ storage, limits: { fileSize: 20 * 1024 * 1024 } })
// ─── GET /api/sessions/:sessionId/series ─────────────────────────────────────
seriesRouter.get('/sessions/:sessionId/series', (req: AuthRequest, res) => {
const sess = db.select().from(training_sessions)
.where(and(eq(training_sessions.id, req.params.sessionId), eq(training_sessions.user_id, req.userId!)))
.get()
if (!sess) return res.status(404).json({ error: 'Séance introuvable' })
const rows = db.select().from(series)
.where(eq(series.session_id, req.params.sessionId))
.all()
return res.json(rows.map(s => ({ ...s, ai_data: safeJson(s.ai_data) })))
})
// ─── POST /api/sessions/:sessionId/series ────────────────────────────────────
seriesRouter.post('/sessions/:sessionId/series', upload.fields([
{ name: 'photo', maxCount: 1 },
{ name: 'annotated', maxCount: 1 },
]), async (req: AuthRequest, res) => {
const {
series_number, shots_declared, shots_detected, shots_manual,
score_zone, score_groupement, score_total,
dispersion_radius, center_x, center_y,
ai_data, impacts: impactsJson,
} = req.body
const id = uuid()
const files = req.files as Record<string, Express.Multer.File[]>
let photo_url: string | null = null
let annotated_url: string | null = null
if (files?.photo?.[0]) {
photo_url = `/uploads/${req.userId}/targets/${files.photo[0].filename}`
}
if (files?.annotated?.[0]) {
annotated_url = `/uploads/${req.userId}/targets/${files.annotated[0].filename}`
}
db.insert(series).values({
id,
session_id: req.params.sessionId,
user_id: req.userId!,
series_number: parseInt(series_number) || 1,
shots_declared: parseInt(shots_declared) || 0,
shots_detected: parseInt(shots_detected) || 0,
shots_manual: parseInt(shots_manual) || 0,
photo_url,
annotated_photo_url: annotated_url,
score_zone: parseInt(score_zone) || 0,
score_groupement: parseInt(score_groupement) || 0,
score_total: parseInt(score_total) || 0,
dispersion_radius: dispersion_radius ? parseFloat(dispersion_radius) : null,
center_x: center_x ? parseFloat(center_x) : null,
center_y: center_y ? parseFloat(center_y) : null,
ai_data: ai_data || null,
}).run()
// Insérer les impacts
if (impactsJson) {
try {
const impList = JSON.parse(impactsJson)
for (const imp of impList) {
db.insert(impacts).values({
id: uuid(),
series_id: id,
user_id: req.userId!,
x: imp.x,
y: imp.y,
zone: imp.zone ?? null,
points: imp.points ?? 0,
is_manual: imp.is_manual ?? false,
}).run()
}
} catch { /* ignore */ }
}
const created = db.select().from(series).where(eq(series.id, id)).get()
return res.status(201).json({ ...created, ai_data: safeJson(created?.ai_data) })
})
// ─── POST /api/series/annotated (upload base64 annotée) ──────────────────────
seriesRouter.post('/series/:id/annotated', (req: AuthRequest, res) => {
const { image_base64 } = req.body
if (!image_base64) return res.status(400).json({ error: 'Image manquante' })
const dir = path.join(process.env.UPLOADS_DIR || './data/uploads', req.userId!, 'targets')
fs.mkdirSync(dir, { recursive: true })
const filename = `annotated_${Date.now()}.jpg`
const b64Data = image_base64.replace(/^data:image\/\w+;base64,/, '')
const buffer = Buffer.from(b64Data, 'base64')
const filepath = path.join(dir, filename)
fs.writeFileSync(filepath, buffer)
const annotated_url = `/uploads/${req.userId}/targets/${filename}`
db.update(series).set({ annotated_photo_url: annotated_url })
.where(and(eq(series.id, req.params.id), eq(series.user_id, req.userId!)))
.run()
return res.json({ annotated_photo_url: annotated_url })
})
function safeJson(str: string | null, fallback: any = null) {
if (!str) return fallback
try { return JSON.parse(str) } catch { return fallback }
}
+132
View File
@@ -0,0 +1,132 @@
import { Router } from 'express'
import { eq, and, desc } from 'drizzle-orm'
import { v4 as uuid } from 'uuid'
import { db } from '../db'
import { training_sessions, weapons, series, impacts, user_locations } from '../db/schema'
import { requireAuth, AuthRequest } from '../middleware/auth'
export const sessionsRouter = Router()
sessionsRouter.use(requireAuth)
// ─── GET /api/sessions ────────────────────────────────────────────────────────
sessionsRouter.get('/', (req: AuthRequest, res) => {
const rows = db.select().from(training_sessions)
.where(eq(training_sessions.user_id, req.userId!))
.orderBy(desc(training_sessions.session_date))
.all()
// Join weapon
const weaponIds = [...new Set(rows.map(r => r.weapon_id).filter(Boolean))]
const weaponMap: Record<string, any> = {}
if (weaponIds.length > 0) {
weaponIds.forEach(wid => {
const w = db.select().from(weapons).where(eq(weapons.id, wid!)).get()
if (w) weaponMap[w.id] = w
})
}
return res.json(rows.map(r => ({
...r,
weapon: r.weapon_id ? weaponMap[r.weapon_id] || null : null,
})))
})
// ─── POST /api/sessions ───────────────────────────────────────────────────────
sessionsRouter.post('/', (req: AuthRequest, res) => {
const { weapon_id, session_date, location, target_type, distance_m, notes_start } = req.body
if (!target_type) return res.status(400).json({ error: 'Type de cible requis' })
const id = uuid()
db.insert(training_sessions).values({
id, user_id: req.userId!,
weapon_id: weapon_id || null,
session_date: session_date || new Date().toISOString(),
location: location || null,
target_type,
distance_m: distance_m ? parseInt(distance_m) : null,
notes_start: notes_start || null,
is_completed: false,
}).run()
// Mémoriser le lieu
if (location?.trim()) {
const existing = db.select().from(user_locations)
.where(and(eq(user_locations.user_id, req.userId!), eq(user_locations.name, location.trim()))).get()
if (existing) {
db.update(user_locations).set({
used_count: (existing.used_count || 0) + 1,
last_used_at: new Date().toISOString(),
}).where(eq(user_locations.id, existing.id)).run()
} else {
db.insert(user_locations).values({
id: uuid(), user_id: req.userId!, name: location.trim(),
}).run()
}
}
const sess = db.select().from(training_sessions).where(eq(training_sessions.id, id)).get()
return res.status(201).json(sess)
})
// ─── GET /api/sessions/:id ────────────────────────────────────────────────────
sessionsRouter.get('/:id', (req: AuthRequest, res) => {
const sess = db.select().from(training_sessions)
.where(and(eq(training_sessions.id, req.params.id), eq(training_sessions.user_id, req.userId!)))
.get()
if (!sess) return res.status(404).json({ error: 'Séance introuvable' })
const weapon = sess.weapon_id
? db.select().from(weapons).where(eq(weapons.id, sess.weapon_id)).get()
: null
const seriesList = db.select().from(series)
.where(eq(series.session_id, sess.id)).all()
const seriesIds = seriesList.map(s => s.id)
let allImpacts: any[] = []
if (seriesIds.length > 0) {
seriesIds.forEach(sid => {
const imp = db.select().from(impacts).where(eq(impacts.series_id, sid)).all()
allImpacts = allImpacts.concat(imp)
})
}
return res.json({
...sess,
weapon,
series: seriesList.map(s => ({
...s,
ai_data: safeJson(s.ai_data),
impacts: allImpacts.filter(i => i.series_id === s.id),
})),
})
})
// ─── PUT /api/sessions/:id ────────────────────────────────────────────────────
sessionsRouter.put('/:id', (req: AuthRequest, res) => {
const sess = db.select().from(training_sessions)
.where(and(eq(training_sessions.id, req.params.id), eq(training_sessions.user_id, req.userId!)))
.get()
if (!sess) return res.status(404).json({ error: 'Séance introuvable' })
db.update(training_sessions).set({
...req.body,
updated_at: new Date().toISOString(),
}).where(eq(training_sessions.id, req.params.id)).run()
const updated = db.select().from(training_sessions).where(eq(training_sessions.id, req.params.id)).get()
return res.json(updated)
})
// ─── DELETE /api/sessions/:id ─────────────────────────────────────────────────
sessionsRouter.delete('/:id', (req: AuthRequest, res) => {
db.delete(training_sessions)
.where(and(eq(training_sessions.id, req.params.id), eq(training_sessions.user_id, req.userId!)))
.run()
return res.json({ message: 'Séance supprimée' })
})
function safeJson(str: string | null, fallback: any = null) {
if (!str) return fallback
try { return JSON.parse(str) } catch { return fallback }
}
+133
View File
@@ -0,0 +1,133 @@
import { Router } from 'express'
import { eq, and } from 'drizzle-orm'
import { v4 as uuid } from 'uuid'
import { db } from '../db'
import { weapons } from '../db/schema'
import { requireAuth, AuthRequest } from '../middleware/auth'
import multer from 'multer'
import path from 'path'
import fs from 'fs'
export const weaponsRouter = Router()
weaponsRouter.use(requireAuth)
// ─── Config multer (photos armes) ────────────────────────────────────────────
const storage = multer.diskStorage({
destination: (req: any, _file, cb) => {
const dir = path.join(process.env.UPLOADS_DIR || './data/uploads', req.userId!, 'weapons')
fs.mkdirSync(dir, { recursive: true })
cb(null, dir)
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname) || '.jpg'
cb(null, `weapon_${Date.now()}${ext}`)
},
})
const upload = multer({ storage, limits: { fileSize: 20 * 1024 * 1024 } })
// ─── GET /api/weapons ─────────────────────────────────────────────────────────
weaponsRouter.get('/', (req: AuthRequest, res) => {
const rows = db.select().from(weapons)
.where(eq(weapons.user_id, req.userId!))
.all()
return res.json(rows)
})
// ─── GET /api/weapons/:id ─────────────────────────────────────────────────────
weaponsRouter.get('/:id', (req: AuthRequest, res) => {
const w = db.select().from(weapons)
.where(and(eq(weapons.id, req.params.id), eq(weapons.user_id, req.userId!)))
.get()
if (!w) return res.status(404).json({ error: 'Arme introuvable' })
return res.json(w)
})
// ─── POST /api/weapons (multipart form) ───────────────────────────────────────
weaponsRouter.post('/', upload.single('photo'), (req: AuthRequest, res) => {
const { name, nickname, type, caliber, brand, model, serial_number, notes } = req.body
if (!name || !type) return res.status(400).json({ error: 'Nom et type requis' })
const id = uuid()
let photo_url: string | null = null
if (req.file) {
photo_url = `/uploads/${req.userId}/weapons/${req.file.filename}`
}
db.insert(weapons).values({
id, user_id: req.userId!, name, nickname: nickname || null, type,
caliber: caliber || null, brand: brand || null, model: model || null,
serial_number: serial_number || null, notes: notes || null,
photo_url, is_archived: false,
}).run()
const w = db.select().from(weapons).where(eq(weapons.id, id)).get()
return res.status(201).json(w)
})
// ─── PUT /api/weapons/:id (multipart form) ────────────────────────────────────
weaponsRouter.put('/:id', upload.single('photo'), (req: AuthRequest, res) => {
const existing = db.select().from(weapons)
.where(and(eq(weapons.id, req.params.id), eq(weapons.user_id, req.userId!))).get()
if (!existing) return res.status(404).json({ error: 'Arme introuvable' })
const { name, nickname, type, caliber, brand, model, serial_number, notes } = req.body
let photo_url = existing.photo_url
if (req.file) {
// Supprimer l'ancienne photo
if (existing.photo_url) {
const old = path.join(process.env.UPLOADS_DIR || './data/uploads',
existing.photo_url.replace(`/uploads/${req.userId}/`, `${req.userId}/`))
if (fs.existsSync(old)) fs.unlinkSync(old)
}
photo_url = `/uploads/${req.userId}/weapons/${req.file.filename}`
}
db.update(weapons).set({
name: name || existing.name,
nickname: nickname ?? existing.nickname,
type: type || existing.type,
caliber: caliber ?? existing.caliber,
brand: brand ?? existing.brand,
model: model ?? existing.model,
serial_number: serial_number ?? existing.serial_number,
notes: notes ?? existing.notes,
photo_url,
updated_at: new Date().toISOString(),
}).where(eq(weapons.id, req.params.id)).run()
const updated = db.select().from(weapons).where(eq(weapons.id, req.params.id)).get()
return res.json(updated)
})
// ─── PATCH /api/weapons/:id/archive ──────────────────────────────────────────
weaponsRouter.patch('/:id/archive', (req: AuthRequest, res) => {
const existing = db.select().from(weapons)
.where(and(eq(weapons.id, req.params.id), eq(weapons.user_id, req.userId!))).get()
if (!existing) return res.status(404).json({ error: 'Arme introuvable' })
db.update(weapons).set({
is_archived: req.body.is_archived ?? !existing.is_archived,
updated_at: new Date().toISOString(),
}).where(eq(weapons.id, req.params.id)).run()
const updated = db.select().from(weapons).where(eq(weapons.id, req.params.id)).get()
return res.json(updated)
})
// ─── DELETE /api/weapons/:id ──────────────────────────────────────────────────
weaponsRouter.delete('/:id', (req: AuthRequest, res) => {
const existing = db.select().from(weapons)
.where(and(eq(weapons.id, req.params.id), eq(weapons.user_id, req.userId!))).get()
if (existing?.photo_url) {
const old = path.join(process.env.UPLOADS_DIR || './data/uploads',
existing.photo_url.replace(`/uploads/${req.userId}/`, `${req.userId}/`))
if (fs.existsSync(old)) fs.unlinkSync(old)
}
db.delete(weapons)
.where(and(eq(weapons.id, req.params.id), eq(weapons.user_id, req.userId!)))
.run()
return res.json({ message: 'Arme supprimée' })
})
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}