feat: ShootTracker SQLite+JWT+YOLOv8
This commit is contained in:
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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'))`),
|
||||
})
|
||||
@@ -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'}`)
|
||||
})
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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' }))
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user