feat: Banque des Coquillages v1.0 avec Docker et configuration NPM

This commit is contained in:
2026-11-11 21:18:25 +01:00
commit 4d7e5a01d0
26 changed files with 8779 additions and 0 deletions

270
back/src/db.js Normal file
View File

@@ -0,0 +1,270 @@
import { DatabaseSync } from 'node:sqlite';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const dataDir = path.join(__dirname, '..', 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
export const uploadsDir = path.join(__dirname, '..', 'uploads');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
const dbPath = path.join(dataDir, 'coquillages.sqlite');
export const db = new DatabaseSync(dbPath);
export const DEFAULT_ACTIVITY_IMAGES = {
pizza: 'https://images.unsplash.com/photo-1513104890138-7c749659a591?auto=format&fit=crop&w=1000&q=80',
bowling: 'https://images.unsplash.com/photo-1538511059256-06459b067f41?auto=format&fit=crop&w=1000&q=80',
aquatique: 'https://images.unsplash.com/photo-1582653291997-079a1c04e5a1?auto=format&fit=crop&w=1000&q=80',
accrobranche: 'https://images.unsplash.com/photo-1542273917363-3b1817f69a2d?auto=format&fit=crop&w=1000&q=80',
default: 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&w=1000&q=80'
};
export function normalizeName(str = '') {
return str
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim();
}
export function initDb() {
db.exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS children (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
avatar TEXT NOT NULL,
color TEXT NOT NULL,
available_shells INTEGER NOT NULL DEFAULT 0,
locked_shells INTEGER NOT NULL DEFAULT 0,
euros_balance REAL NOT NULL DEFAULT 0,
shells_converted_month INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
emoji TEXT NOT NULL DEFAULT '🎡',
image_url TEXT NOT NULL DEFAULT '',
cost_shells INTEGER NOT NULL,
current_shells INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'ACTIVE',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS activity_contributions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
activity_id INTEGER NOT NULL,
child_id INTEGER NOT NULL,
shells INTEGER NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (activity_id) REFERENCES activities(id),
FOREIGN KEY (child_id) REFERENCES children(id)
);
CREATE TABLE IF NOT EXISTS pending_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL,
type TEXT NOT NULL,
shells INTEGER NOT NULL,
activity_id INTEGER,
source TEXT NOT NULL DEFAULT 'ALEXA',
status TEXT NOT NULL DEFAULT 'PENDING',
note TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
resolved_at TEXT,
FOREIGN KEY (child_id) REFERENCES children(id),
FOREIGN KEY (activity_id) REFERENCES activities(id)
);
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER,
type TEXT NOT NULL,
shells_delta INTEGER NOT NULL DEFAULT 0,
euros_delta REAL NOT NULL DEFAULT 0,
description TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (child_id) REFERENCES children(id)
);
`);
// Migration douce : ajouter image_url si la table activities existait déjà sans cette colonne
const columns = db.prepare("PRAGMA table_info('activities')").all();
const hasImageUrl = columns.some((c) => c.name === 'image_url');
if (!hasImageUrl) {
db.exec("ALTER TABLE activities ADD COLUMN image_url TEXT NOT NULL DEFAULT ''");
}
// Migration douce : ajouter alexa_person_id sur la table children pour la reconnaissance vocale Alexa (Voice ID)
const childColumns = db.prepare("PRAGMA table_info('children')").all();
const hasPersonId = childColumns.some((c) => c.name === 'alexa_person_id');
if (!hasPersonId) {
db.exec("ALTER TABLE children ADD COLUMN alexa_person_id TEXT DEFAULT NULL");
}
// Migration douce : ajouter proposed_title sur la table pending_requests pour les propositions d'activités des enfants
const prColumns = db.prepare("PRAGMA table_info('pending_requests')").all();
const hasProposedTitle = prColumns.some((c) => c.name === 'proposed_title');
if (!hasProposedTitle) {
db.exec("ALTER TABLE pending_requests ADD COLUMN proposed_title TEXT DEFAULT NULL");
}
// Remplir les images par défaut pour les activités existantes qui n'en ont pas encore
const existingActivities = db.prepare('SELECT id, title, image_url FROM activities').all();
const updateImg = db.prepare('UPDATE activities SET image_url = ? WHERE id = ?');
for (const act of existingActivities) {
if (!act.image_url) {
const norm = normalizeName(act.title);
let img = DEFAULT_ACTIVITY_IMAGES.default;
if (norm.includes('pizza') || norm.includes('cinema')) img = DEFAULT_ACTIVITY_IMAGES.pizza;
else if (norm.includes('bowling')) img = DEFAULT_ACTIVITY_IMAGES.bowling;
else if (norm.includes('aquatique') || norm.includes('piscine')) img = DEFAULT_ACTIVITY_IMAGES.aquatique;
else if (norm.includes('accrobranche')) img = DEFAULT_ACTIVITY_IMAGES.accrobranche;
updateImg.run(img, act.id);
}
}
// Seed default settings if empty
const existingRate = db.prepare('SELECT value FROM settings WHERE key = ?').get('interest_rate_percent');
if (!existingRate) {
const insertSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
insertSetting.run('interest_rate_percent', '20');
insertSetting.run('shells_per_euro', '2');
insertSetting.run('max_euros_per_month', '5');
insertSetting.run('max_shells_euro_month', '10');
insertSetting.run('last_interest_run', 'Jamais');
}
// Seed children: Hailey, Violette, Andreas
const childCount = db.prepare('SELECT COUNT(*) as count FROM children').get().count;
if (childCount === 0) {
const insertChild = db.prepare(`
INSERT INTO children (name, slug, avatar, color, available_shells, locked_shells, euros_balance, shells_converted_month)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
insertChild.run('Hailey', 'hailey', '🧜‍♀️', 'pink', 12, 10, 1.5, 3);
insertChild.run('Violette', 'violette', '🐬', 'purple', 8, 15, 2.0, 4);
insertChild.run('Andreas', 'andreas', '🦈', 'cyan', 14, 10, 1.0, 2);
}
// Seed activities if empty
const activityCount = db.prepare('SELECT COUNT(*) as count FROM activities').get().count;
if (activityCount === 0) {
const now = new Date().toISOString();
const insertActivity = db.prepare(`
INSERT INTO activities (title, description, emoji, image_url, cost_shells, current_shells, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
insertActivity.run(
'Soirée Pizza & Cinéma',
'Chacun choisit sa pizza et on regarde un grand film en famille avec du pop-corn !',
'🍕',
DEFAULT_ACTIVITY_IMAGES.pizza,
25,
12,
'ACTIVE',
now
);
insertActivity.run(
'Bowling en famille',
'Sortie au bowling avec 2 parties et boissons pour tout le monde.',
'🎳',
DEFAULT_ACTIVITY_IMAGES.bowling,
45,
15,
'ACTIVE',
now
);
insertActivity.run(
'Parc Aquatique',
'Une après-midi complète dans les toboggans géants et la piscine à vagues.',
'🌊',
DEFAULT_ACTIVITY_IMAGES.aquatique,
70,
0,
'ACTIVE',
now
);
insertActivity.run(
'Sortie Accrobranche',
'Parcours dans les arbres et tyroliennes pour Hailey, Violette et Andreas.',
'🧗',
DEFAULT_ACTIVITY_IMAGES.accrobranche,
60,
0,
'ACTIVE',
now
);
const insertContribution = db.prepare(`
INSERT INTO activity_contributions (activity_id, child_id, shells, created_at)
VALUES (?, ?, ?, ?)
`);
insertContribution.run(1, 1, 4, now);
insertContribution.run(1, 2, 4, now);
insertContribution.run(1, 3, 4, now);
insertContribution.run(2, 1, 5, now);
insertContribution.run(2, 2, 5, now);
insertContribution.run(2, 3, 5, now);
const insertRequest = db.prepare(`
INSERT INTO pending_requests (child_id, type, shells, activity_id, source, status, note, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
insertRequest.run(
1,
'DEPOSIT_LOCKED',
6,
null,
'ALEXA',
'PENDING',
'Hailey a demandé sur Alexa de déposer et bloquer 6 coquillages physiques pour gagner des intérêts.',
now
);
const insertTx = db.prepare(`
INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
insertTx.run(1, 'INIT', 22, 1.5, 'Ouverture du compte coquillages de Hailey', now);
insertTx.run(2, 'INIT', 23, 2.0, 'Ouverture du compte coquillages de Violette', now);
insertTx.run(3, 'INIT', 24, 1.0, 'Ouverture du compte coquillages d\'Andreas', now);
}
}
export function getSettings() {
const rows = db.prepare('SELECT key, value FROM settings').all();
const map = {};
for (const r of rows) {
map[r.key] = r.value;
}
return {
interest_rate_percent: Number(map.interest_rate_percent ?? 20),
shells_per_euro: Number(map.shells_per_euro ?? 2),
max_euros_per_month: Number(map.max_euros_per_month ?? 5),
max_shells_euro_month: Number(map.max_shells_euro_month ?? 10),
last_interest_run: map.last_interest_run ?? 'Jamais'
};
}
export function calculateProjectedInterest(lockedShells, ratePercent) {
if (lockedShells <= 0 || ratePercent <= 0) return 0;
return Math.ceil((lockedShells * ratePercent) / 100);
}

1203
back/src/server.js Normal file

File diff suppressed because it is too large Load Diff