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

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules
*/node_modules
*/*/node_modules
.git
.gemini
back/data/*.sqlite*
back/uploads/*
front/dist
dist
*.log

34
.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# Dépendances Node
node_modules/
back/node_modules/
front/node_modules/
skill/lambda/node_modules/
# Builds et artefacts
front/dist/
dist/
# Données locales persistées via Docker volumes
data/
uploads/
back/data/*.sqlite*
!back/data/.gitkeep
back/uploads/*
!back/uploads/.gitkeep
# Fichiers d'environnement
.env
.env.local
.env.*.local
# Logs
*.log
npm-debug.log*
yarn-debug.log*
# Fichiers système & IDE
.DS_Store
Thumbs.db
.gemini/
.idea/
.vscode/

124
README.md Normal file
View File

@@ -0,0 +1,124 @@
# 🐚 La Banque des Coquillages — Hailey, Violette & Andreas
Application familiale **Physique + Digitale** (Site Web + API + Skill Alexa Echo Show) pour gérer les coquillages de **Hailey**, **Violette** et **Andreas**, leurs activités communes du mois, leur tirelire en euros (max 5 € / mois = 10 coquillages) et leur livret d'épargne bloquée avec intérêts mensuels.
---
## 📁 Structure du Projet
```text
arrweek/
├── back/ # API REST Node.js 22 + Express + SQLite (node:sqlite) + Webhook Alexa
│ └── Dockerfile # Image Docker backend de production (Node 22 Alpine)
├── front/ # Application Web React + Vite + Tailwind CSS
│ ├── Dockerfile # Image Docker frontend multi-stage (Node 22 + Nginx Alpine)
│ └── nginx.conf # Configuration Nginx avec reverse proxy (/api/ & /uploads/)
├── skill/ # Skill Alexa officiel
│ ├── interactionModels/ # Modèle d'interaction vocal (fr-FR.json)
│ └── lambda/ # Handler ASK SDK avec écrans APL 1.8 tactiles pour Echo Show
├── docker-compose.yml # Déploiement Docker complet (backend + frontend + volumes)
└── package.json # Monorepo npm workspaces
```
---
## 🐳 Déploiement Docker & Nginx Proxy Manager (NPM)
### 1. Démarrer avec Docker Compose
Sur votre serveur Docker :
```bash
# Cloner le dépôt depuis votre git perso
git clone https://git.home.arrondeau.fr/<votre-utilisateur>/<votre-depot>.git
cd <votre-depot>
# Créer les dossiers de persistance
mkdir -p data uploads
# Lancer la stack en arrière-plan
docker compose up -d --build
```
Services démarrés :
- **`coquillages-frontend`** : port `5180` (sert l'application React et relaie automatiquement `/api/` et `/uploads/` vers le backend).
- **`coquillages-backend`** : port `4050` (API Node.js + base SQLite persistée dans `./data` et images dans `./uploads`).
---
### 🌐 2. Configuration dans Nginx Proxy Manager (NPM)
Dans l'interface web de votre **Nginx Proxy Manager** :
#### Option A (Recommandée — Domaine unique tout-en-un) :
Créez un nouveau **Proxy Host** :
1. **Domain Names** : `coquillages.home.arrondeau.fr` (ou votre sous-domaine choisi)
2. **Scheme** : `http`
3. **Forward Hostname / IP** : `IP_DE_VOTRE_SERVEUR_DOCKER` (ou le nom du conteneur si NPM est sur le même réseau Docker : `coquillages-frontend`)
4. **Forward Port** : `5180` (ou `80` en interne)
5. **Options recommandées** :
- [x] **Block Common Exploits**
- [x] **Websockets Support**
6. **Onglet SSL** :
- Sélectionnez ou générez un certificat SSL Let's Encrypt
- [x] **Force SSL**
- [x] **HTTP/2 Support**
- [x] **HSTS Enabled**
> 🎉 **Résultat :**
> - **Application Web** : `https://coquillages.home.arrondeau.fr`
> - **API REST** : `https://coquillages.home.arrondeau.fr/api/...`
> - **Endpoint Alexa Webhook permanent** : `https://coquillages.home.arrondeau.fr/api/alexa/webhook` *(plus besoin de tunnel SSH ou localhost.run !)*
---
## 🎙️ 3. Configuration de l'Endpoint dans la Console Alexa Developer
Une fois exposé via NPM avec votre certificat HTTPS :
1. Allez sur la [Console Alexa Developer](https://developer.amazon.com/alexa/console/ask).
2. Ouvrez votre Skill **La Banque des Coquillages**.
3. Dans **Endpoint** :
- Sélectionnez **HTTPS**.
- **Default Region** : `https://coquillages.home.arrondeau.fr/api/alexa/webhook`
- Sélectionnez l'option de certificat SSL : *« My development endpoint has a Certificate from a trusted certificate authority »* (Let's Encrypt).
- Cliquez sur **Save Endpoints**.
4. Dans **Interaction Model > JSON Editor** :
- Collez le contenu de [`skill/interactionModels/custom/fr-FR.json`](./skill/interactionModels/custom/fr-FR.json).
- Cliquez sur **Save Model** puis **Build Model**.
---
## 📦 4. Initialisation & Push sur votre Git perso (`git.home.arrondeau.fr`)
Pour initialiser ce dépôt local et le pousser sur votre instance Git personnelle :
```bash
# 1. Initialiser le dépôt git localement
git init
# 2. Ajouter tous les fichiers (le .gitignore protège les node_modules et sqlite)
git add .
# 3. Premier commit
git commit -m "feat: Banque des Coquillages v1.0 avec Docker et support NPM"
# 4. Définir la branche principale
git branch -M main
# 5. Lier votre serveur Git perso
git remote add origin https://git.home.arrondeau.fr/<votre-utilisateur>/<nom-du-depot>.git
# 6. Pousser le code
git push -u origin main
```
---
## 💻 Développement Local (sans Docker)
```bash
# Lancer Backend + Frontend simultanément
npm run start:all
# Ou séparément :
npm run dev:back # Backend sur http://localhost:4050
npm run dev:front # Frontend Vite sur http://localhost:5180
```

27
back/Dockerfile Normal file
View 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
View File

15
back/package.json Normal file
View 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
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

0
back/uploads/.gitkeep Normal file
View File

37
docker-compose.yml Normal file
View File

@@ -0,0 +1,37 @@
services:
backend:
build:
context: .
dockerfile: back/Dockerfile
container_name: coquillages-backend
restart: unless-stopped
ports:
# Port exposé pour accès direct ou pour Nginx Proxy Manager (NPM)
- "4050:4050"
environment:
- NODE_ENV=production
- PORT=4050
volumes:
# Persistance de la base SQLite et des images téléversées
- ./data:/app/back/data
- ./uploads:/app/back/uploads
networks:
- coquillages-net
frontend:
build:
context: .
dockerfile: front/Dockerfile
container_name: coquillages-frontend
restart: unless-stopped
ports:
# Port exposé pour l'accès web ou pour Nginx Proxy Manager (NPM)
- "5180:80"
depends_on:
- backend
networks:
- coquillages-net
networks:
coquillages-net:
driver: bridge

30
front/Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
# Étape 1 : Build de l'application React Vite
FROM node:22-alpine AS builder
WORKDIR /app
# Copie des configurations de paquets du monorepo
COPY package.json package-lock.json ./
COPY front/package.json ./front/
# Installation des dépendances du frontend
RUN npm install --workspace=front
# Copie du code source frontend
COPY front ./front
# Construction du bundle statique de production
RUN npm --workspace=front run build
# Étape 2 : Image Nginx ultra-légère pour servir les fichiers statiques et relayer l'API
FROM nginx:alpine
# Copie des fichiers statiques générés
COPY --from=builder /app/front/dist /usr/share/nginx/html
# Configuration Nginx personnalisée avec reverse proxy vers backend:4050
COPY front/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

13
front/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>🐚 La Banque des Coquillages — Hailey, Violette & Andreas</title>
</head>
<body class="bg-slate-950 text-slate-100 antialiased selection:bg-amber-400 selection:text-slate-950">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

40
front/nginx.conf Normal file
View File

@@ -0,0 +1,40 @@
server {
listen 80;
server_name localhost;
# Compression gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
root /usr/share/nginx/html;
index index.html;
# Relai vers le backend Express pour toutes les requêtes API
location /api/ {
proxy_pass http://backend:4050/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_cache_bypass $http_upgrade;
}
# Relai vers les images uploadées servies par le backend
location /uploads/ {
proxy_pass http://backend:4050/uploads/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
# Application React (Single Page Application - fallback sur index.html)
location / {
try_files $uri $uri/ /index.html;
}
}

25
front/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "arrweek-front",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5180",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.475.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.6",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.6",
"vite": "^6.1.0"
}
}

1762
front/src/App.jsx Normal file

File diff suppressed because it is too large Load Diff

2
front/src/index.css Normal file
View File

@@ -0,0 +1,2 @@
@import "tailwindcss";

11
front/src/main.jsx Normal file
View File

@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

21
front/vite.config.js Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5180,
proxy: {
'/api': {
target: 'http://localhost:4050',
changeOrigin: true
},
'/uploads': {
target: 'http://localhost:4050',
changeOrigin: true
}
}
}
});

3391
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "arrweek-monorepo",
"private": true,
"type": "module",
"workspaces": [
"back",
"front",
"skill/lambda"
],
"scripts": {
"start:all": "node start-all.js",
"tunnel": "ssh -o StrictHostKeyChecking=no -R 80:localhost:4050 nokey@localhost.run",
"start:back": "npm --workspace=back start",
"dev:back": "npm --workspace=back run dev",
"dev:front": "npm --workspace=front run dev",
"build:front": "npm --workspace=front run build",
"test:skill": "npm --workspace=skill/lambda test"
}
}

View File

@@ -0,0 +1,388 @@
{
"interactionModel": {
"languageModel": {
"invocationName": "les coquillages",
"intents": [
{
"name": "AMAZON.CancelIntent",
"samples": []
},
{
"name": "AMAZON.HelpIntent",
"samples": []
},
{
"name": "AMAZON.StopIntent",
"samples": []
},
{
"name": "AMAZON.NavigateHomeIntent",
"samples": []
},
{
"name": "OpenBankIntent",
"slots": [],
"samples": [
"la banque",
"ouvre la banque",
"la banque des coquillages",
"ouvre la banque des coquillages",
"la boîte",
"ouvre la boîte",
"la boîte à coquillages",
"la boîte des coquillages",
"ouvre la boîte à coquillages",
"ouvre la boîte des coquillages",
"tableau de bord",
"retour à la banque",
"retour à l'accueil"
]
},
{
"name": "IdentifySpeakerIntent",
"slots": [
{
"name": "ChildName",
"type": "CHILD_NAME"
}
],
"samples": [
"je suis {ChildName}",
"c'est {ChildName}",
"moi c'est {ChildName}",
"associe ma voix à {ChildName}",
"enregistre ma voix pour {ChildName}"
]
},
{
"name": "GetBalanceIntent",
"slots": [
{
"name": "ChildName",
"type": "CHILD_NAME"
}
],
"samples": [
"combien j'ai de coquillages",
"bonjour combien j'ai de coquillages",
"combien ai-je de coquillages",
"j'ai combien de coquillages",
"combien j'ai",
"quel est mon solde",
"où en est mon compte",
"combien j'ai dans ma tirelire",
"combien de coquillages a {ChildName}",
"quel est le solde de {ChildName}",
"combien a {ChildName}",
"combien {ChildName} a de coquillages",
"où en est {ChildName}",
"quel est notre bilan",
"combien on a de coquillages en banque",
"quel est le solde de la famille",
"combien d'argent a {ChildName}"
]
},
{
"name": "ListActivitiesIntent",
"slots": [
{
"name": "ShellCount",
"type": "AMAZON.NUMBER"
}
],
"samples": [
"quelles sont les activités",
"quelles sont les activités du mois",
"quelles sont les activités disponibles",
"qu'est-ce qu'on peut faire comme activité",
"que peut-on faire avec {ShellCount} coquillages",
"quelles activités on peut payer avec {ShellCount} coquillages",
"qu'est-ce qu'on peut acheter avec {ShellCount} coquillages",
"liste les activités"
]
},
{
"name": "DepositShellsIntent",
"slots": [
{
"name": "ChildName",
"type": "CHILD_NAME"
},
{
"name": "ShellCount",
"type": "AMAZON.NUMBER"
},
{
"name": "AccountType",
"type": "ACCOUNT_TYPE"
}
],
"samples": [
"je veux bloquer {ShellCount} coquillages",
"je bloque {ShellCount} coquillages",
"je veux placer {ShellCount} coquillages",
"je dépose {ShellCount} coquillages",
"je place {ShellCount} coquillages sur mon compte {AccountType}",
"{ChildName} veut bloquer {ShellCount} coquillages",
"{ChildName} bloque {ShellCount} coquillages",
"{ChildName} veut placer {ShellCount} coquillages",
"{ChildName} place {ShellCount} coquillages sur son compte {AccountType}",
"{ChildName} veut déposer {ShellCount} coquillages sur son compte {AccountType}",
"{ChildName} dépose {ShellCount} coquillages",
"bloquer {ShellCount} coquillages pour {ChildName}",
"placer {ShellCount} coquillages pour {ChildName}"
]
},
{
"name": "ContributeActivityIntent",
"slots": [
{
"name": "ChildName",
"type": "CHILD_NAME"
},
{
"name": "ShellCount",
"type": "AMAZON.NUMBER"
},
{
"name": "ActivityName",
"type": "ACTIVITY_NAME"
}
],
"samples": [
"je veux ajouter des coquillages sur {ActivityName}",
"je veux mettre des coquillages pour {ActivityName}",
"j'ajoute des coquillages pour {ActivityName}",
"je participe à {ActivityName}",
"{ChildName} veut ajouter des coquillages sur {ActivityName}",
"{ChildName} veut mettre des coquillages pour {ActivityName}",
"{ChildName} ajoute des coquillages sur {ActivityName}",
"{ChildName} participe à {ActivityName}",
"je mets {ShellCount} coquillages pour {ActivityName}",
"je donne {ShellCount} coquillages pour {ActivityName}",
"je veux mettre {ShellCount} coquillages sur {ActivityName}",
"je cotise {ShellCount} coquillages pour {ActivityName}",
"{ChildName} met {ShellCount} coquillages pour {ActivityName}",
"{ChildName} donne {ShellCount} coquillages pour {ActivityName}",
"{ChildName} veut mettre {ShellCount} coquillages sur {ActivityName}",
"mettre {ShellCount} coquillages de {ChildName} pour {ActivityName}",
"{ChildName} cotise {ShellCount} coquillages pour {ActivityName}"
]
},
{
"name": "ConvertEurosIntent",
"slots": [
{
"name": "ChildName",
"type": "CHILD_NAME"
},
{
"name": "ShellCount",
"type": "AMAZON.NUMBER"
}
],
"samples": [
"je veux convertir {ShellCount} coquillages en euros",
"je veux échanger {ShellCount} coquillages contre des euros",
"j'échange {ShellCount} coquillages en euros",
"je mets {ShellCount} coquillages dans ma tirelire en euros",
"{ChildName} veut convertir {ShellCount} coquillages en euros",
"{ChildName} veut échanger {ShellCount} coquillages contre des euros",
"{ChildName} échange {ShellCount} coquillages en euros",
"convertir {ShellCount} coquillages en euros pour {ChildName}",
"{ChildName} met {ShellCount} coquillages dans sa tirelire en euros"
]
},
{
"name": "SimulateInterestIntent",
"slots": [
{
"name": "ShellCount",
"type": "AMAZON.NUMBER"
}
],
"samples": [
"combien rapportent {ShellCount} coquillages bloqués",
"quel est l'intérêt pour {ShellCount} coquillages",
"si je bloque {ShellCount} coquillages je gagne combien",
"calcule les intérêts pour {ShellCount} coquillages"
]
},
{
"name": "GetActivityDetailIntent",
"slots": [
{
"name": "ActivityName",
"type": "ACTIVITY_NAME"
}
],
"samples": [
"affiche les détails de {ActivityName}",
"montre les détails de {ActivityName}",
"donne moi les détails de {ActivityName}",
"affiche l'activité {ActivityName}",
"montre moi l'activité {ActivityName}",
"où en est {ActivityName}",
"où en est l'activité {ActivityName}",
"combien il manque pour {ActivityName}",
"détails de {ActivityName}"
]
},
{
"name": "ProposeActivityIntent",
"slots": [
{
"name": "ChildName",
"type": "CHILD_NAME"
},
{
"name": "ActivityTitle",
"type": "AMAZON.SearchQuery"
}
],
"samples": [
"je propose l'activité {ActivityTitle}",
"je propose l'activité de {ActivityTitle}",
"je propose comme activité {ActivityTitle}",
"je propose {ActivityTitle}",
"je veux proposer l'activité {ActivityTitle}",
"je voudrais proposer l'activité {ActivityTitle}",
"je veux proposer comme activité {ActivityTitle}",
"je voudrais proposer {ActivityTitle}",
"je veux proposer {ActivityTitle}",
"propose l'activité {ActivityTitle}",
"propose comme activité {ActivityTitle}",
"proposer l'activité {ActivityTitle}",
"nouvelle activité {ActivityTitle}",
"ajoute l'activité {ActivityTitle}",
"je veux faire l'activité {ActivityTitle}",
"on pourrait faire l'activité {ActivityTitle}",
"on pourrait faire {ActivityTitle}",
"{ChildName} propose l'activité {ActivityTitle}",
"{ChildName} propose comme activité {ActivityTitle}",
"{ChildName} propose {ActivityTitle}",
"{ChildName} veut proposer l'activité {ActivityTitle}",
"{ChildName} veut proposer {ActivityTitle}"
]
}
],
"types": [
{
"name": "CHILD_NAME",
"values": [
{
"id": "hailey",
"name": {
"value": "Hailey",
"synonyms": [
"Hailey",
"Haley",
"Ailey",
"Élie",
"Hély"
]
}
},
{
"id": "violette",
"name": {
"value": "Violette",
"synonyms": [
"Violette",
"Violet"
]
}
},
{
"id": "andreas",
"name": {
"value": "Andreas",
"synonyms": [
"Andreas",
"Andréas",
"Andrea",
"Andréa"
]
}
}
]
},
{
"name": "ACCOUNT_TYPE",
"values": [
{
"id": "bloque",
"name": {
"value": "bloqué",
"synonyms": [
"bloqué",
"épargne",
"livret",
"intérêts"
]
}
},
{
"id": "disponible",
"name": {
"value": "disponible",
"synonyms": [
"disponible",
"courant",
"normal",
"virtuel"
]
}
}
]
},
{
"name": "ACTIVITY_NAME",
"values": [
{
"name": {
"value": "Soirée Pizza et Cinéma",
"synonyms": [
"pizza",
"cinéma",
"soirée pizza",
"film"
]
}
},
{
"name": {
"value": "Bowling en famille",
"synonyms": [
"bowling",
"le bowling"
]
}
},
{
"name": {
"value": "Parc Aquatique",
"synonyms": [
"parc aquatique",
"piscine",
"toboggans"
]
}
},
{
"name": {
"value": "Sortie Accrobranche",
"synonyms": [
"accrobranche",
"l'accrobranche",
"tyrolienne"
]
}
}
]
}
]
}
}
}

1197
skill/lambda/index.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
import { handler } from './index.js';
function buildIntentEvent(intentName, slots = {}) {
const formattedSlots = {};
for (const [k, v] of Object.entries(slots)) {
formattedSlots[k] = { name: k, value: String(v) };
}
return {
version: '1.0',
session: { new: false, sessionId: 'SessionId.test', application: { applicationId: 'amzn1.ask.skill.test' } },
context: { System: { application: { applicationId: 'amzn1.ask.skill.test' } } },
request: {
type: 'IntentRequest',
requestId: 'EdwRequestId.test',
timestamp: new Date().toISOString(),
locale: 'fr-FR',
intent: {
name: intentName,
confirmationStatus: 'NONE',
slots: formattedSlots
}
}
};
}
async function runTests() {
console.log('🎙️ Test local du Skill Alexa "La Banque des Coquillages"...\n');
const tests = [
{ label: 'Solde de Violette', event: buildIntentEvent('GetBalanceIntent', { ChildName: 'Violette' }) },
{ label: 'Activités du mois', event: buildIntentEvent('ListActivitiesIntent', {}) },
{ label: 'Simulation intérêts (10 coquillages)', event: buildIntentEvent('SimulateInterestIntent', { ShellCount: 10 }) }
];
for (const t of tests) {
try {
const res = await new Promise((resolve, reject) => {
handler(t.event, {}, (err, response) => (err ? reject(err) : resolve(response)));
});
console.log(`✅ [${t.label}] ->`, res.response.outputSpeech.ssml);
} catch (e) {
console.error(`❌ [${t.label}] -> Erreur (assurez-vous que 'back' tourne sur le port 4000):`, e.message);
}
}
}
runTests();

15
skill/lambda/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "banque-coquillages-skill-lambda",
"version": "1.0.0",
"description": "AWS Lambda / Alexa Skill Handler pour La Banque des Coquillages (Hailey, Violette et Andreas)",
"type": "module",
"main": "index.js",
"scripts": {
"test": "node local-test.js"
},
"dependencies": {
"ask-sdk-core": "^2.14.0",
"ask-sdk-model": "^1.40.0"
}
}

43
skill/skill.json Normal file
View File

@@ -0,0 +1,43 @@
{
"manifest": {
"publishingInformation": {
"locales": {
"fr-FR": {
"summary": "La Banque des Coquillages pour Hailey, Violette et Andreas",
"examplePhrases": [
"Alexa, ouvre la banque des coquillages",
"Alexa, demande à la banque des coquillages combien de coquillages a Hailey",
"Alexa, demande à la banque des coquillages quelles sont les activités du mois"
],
"keywords": [
"coquillages",
"enfants",
"récompenses",
"épargne",
"famille"
],
"name": "La Banque des Coquillages",
"description": "Skill familial permettant à Hailey, Violette et Andreas de consulter leurs coquillages, de financer des activités communes et de placer leurs coquillages sur un livret bloqué à intérêts."
}
},
"isAvailableWorldwide": false,
"testingInstructions": "Utiliser avec le backend API familial.",
"category": "EDUCATION_AND_REFERENCE",
"distributionCountries": [
"FR",
"BE",
"CH",
"CA"
]
},
"apis": {
"custom": {
"endpoint": {
"uri": "arn:aws:lambda:eu-west-1:000000000000:function:banque-coquillages-skill"
}
}
},
"manifestVersion": "1.0"
}
}

54
start-all.js Normal file
View File

@@ -0,0 +1,54 @@
import { spawn } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function runNode(name, colorCode, args, cwd) {
const child = spawn(process.execPath, args, {
cwd,
stdio: 'pipe'
});
child.stdout.on('data', (data) => {
const lines = data.toString().split(/\r?\n/).filter(Boolean);
for (const line of lines) {
console.log(`\x1b[${colorCode}m[${name}]\x1b[0m ${line}`);
}
});
child.stderr.on('data', (data) => {
const lines = data.toString().split(/\r?\n/).filter(Boolean);
for (const line of lines) {
console.error(`\x1b[${colorCode}m[${name}]\x1b[0m ${line}`);
}
});
return child;
}
console.log('🐚 Démarrage simultané du Backend (port 4050) et du Frontend (port 5180)...');
const back = runNode(
'BACK ',
'36',
['--watch', path.join(__dirname, 'back', 'src', 'server.js')],
path.join(__dirname, 'back')
);
const front = runNode(
'FRONT',
'33',
[path.join(__dirname, 'node_modules', 'vite', 'bin', 'vite.js')],
path.join(__dirname, 'front')
);
function shutdown() {
back.kill();
front.kill();
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);