feat: Banque des Coquillages v1.0 avec Docker et configuration NPM
This commit is contained in:
27
back/Dockerfile
Normal file
27
back/Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copie des définitions de packages du monorepo
|
||||
COPY package.json package-lock.json ./
|
||||
COPY back/package.json ./back/
|
||||
COPY skill/lambda/package.json ./skill/lambda/
|
||||
|
||||
# Installation des dépendances de production
|
||||
RUN npm install --omit=dev --workspace=back --workspace=skill/lambda
|
||||
|
||||
# Copie des sources du backend et du skill Alexa (utilisé par le webhook Alexa)
|
||||
COPY back ./back
|
||||
COPY skill ./skill
|
||||
|
||||
# Création des dossiers de persistance
|
||||
RUN mkdir -p /app/back/data /app/back/uploads
|
||||
|
||||
WORKDIR /app/back
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=4050
|
||||
|
||||
EXPOSE 4050
|
||||
|
||||
CMD ["node", "src/server.js"]
|
||||
0
back/data/.gitkeep
Normal file
0
back/data/.gitkeep
Normal file
15
back/package.json
Normal file
15
back/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "arrweek-back",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend API & Moteur Bancaire pour la Banque des Coquillages (Hailey, Violette & Andreas)",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"dev": "node --watch src/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2"
|
||||
}
|
||||
}
|
||||
|
||||
270
back/src/db.js
Normal file
270
back/src/db.js
Normal 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
1203
back/src/server.js
Normal file
File diff suppressed because it is too large
Load Diff
0
back/uploads/.gitkeep
Normal file
0
back/uploads/.gitkeep
Normal file
Reference in New Issue
Block a user