Compare commits

...

4 Commits

4 changed files with 1093 additions and 137 deletions

View File

@@ -22,10 +22,27 @@ arrweek/
---
## 🐳 Déploiement Docker & Nginx Proxy Manager (NPM)
## 🐳 Déploiement avec Portainer
### 1. Démarrer avec Docker Compose
Sur votre serveur Docker :
Grâce à votre dépôt sur `git.home.arrondeau.fr`, vous pouvez déployer et mettre à jour la stack directement depuis l'interface **Portainer** :
### 1. Créer la Stack dans Portainer
1. Dans Portainer, allez dans **Stacks** puis cliquez sur **+ Add stack**.
2. Nommez la stack (ex: `coquillages`).
3. Choisissez l'option **Repository** :
- **Repository URL** : `https://git.home.arrondeau.fr/<votre-utilisateur>/<votre-depot>.git`
- **Repository reference** : `refs/heads/main`
- **Compose path** : `docker-compose.yml`
- Si votre dépôt Git est privé, activez **Authentication** et renseignez votre nom d'utilisateur et mot de passe (ou jeton d'accès personnel).
4. *(Optionnel)* Activez **Automatic updates** (polling ou Webhook) pour que Portainer redéploie automatiquement l'application à chaque `git push` !
5. Cliquez sur **Deploy the stack**.
> 💡 **Persistance des données dans Portainer :**
> La stack utilise deux volumes Docker nommés (`coquillages_data` et `coquillages_uploads`). Vos données (comptes, tirelire, historique des coquillages, images) sont visibles dans l'onglet **Volumes** de Portainer et restent 100% préservées lors des mises à jour ou recréations de stack.
---
## 🐳 Déploiement en ligne de commande (Docker Compose classique)
```bash
# Cloner le dépôt depuis votre git perso
git clone https://git.home.arrondeau.fr/<votre-utilisateur>/<votre-depot>.git

View File

@@ -270,13 +270,17 @@ app.patch('/api/activities/:id', (req, res) => {
const emoji = req.body.emoji ?? existing.emoji;
const image_url = req.body.image_url !== undefined ? req.body.image_url : existing.image_url;
const cost_shells = req.body.cost_shells !== undefined ? Number(req.body.cost_shells) : existing.cost_shells;
const status = req.body.status ?? existing.status;
const current_shells = req.body.current_shells !== undefined ? Math.max(0, Number(req.body.current_shells)) : existing.current_shells;
let status = req.body.status ?? existing.status;
if (current_shells >= cost_shells && status === 'ACTIVE') {
status = 'UNLOCKED';
}
db.prepare(`
UPDATE activities
SET title = ?, description = ?, emoji = ?, image_url = ?, cost_shells = ?, status = ?
SET title = ?, description = ?, emoji = ?, image_url = ?, cost_shells = ?, current_shells = ?, status = ?
WHERE id = ?
`).run(title, description, emoji, image_url, cost_shells, status, id);
`).run(title, description, emoji, image_url, cost_shells, current_shells, status, id);
res.json({ message: 'Activité mise à jour.' });
});
@@ -290,6 +294,71 @@ app.delete('/api/activities/:id', (req, res) => {
res.json({ message: 'Activité supprimée.' });
});
// 6b. POST /api/activities/:id/direct-contribute (Attribution directe de coquillages par les parents pour un enfant)
app.post('/api/activities/:id/direct-contribute', (req, res) => {
const activityId = Number(req.params.id);
const activity = db.prepare('SELECT * FROM activities WHERE id = ?').get(activityId);
if (!activity) return res.status(404).json({ error: 'Activité introuvable.' });
const { child_id, shells, source_type = 'PHYSICAL' } = req.body;
const child = db.prepare('SELECT * FROM children WHERE id = ?').get(Number(child_id));
if (!child) return res.status(404).json({ error: 'Enfant introuvable.' });
const count = Number(shells);
if (!count || count <= 0) {
return res.status(400).json({ error: 'Veuillez saisir un nombre de coquillages supérieur à 0.' });
}
const isVirtual = source_type === 'VIRTUAL';
if (isVirtual && child.available_shells < count) {
return res.status(400).json({
error: `Solde virtuel insuffisant : ${child.name} n'a que ${child.available_shells} coquillages disponibles.`
});
}
const now = new Date().toISOString();
// Déduire si c'est virtuel
if (isVirtual) {
db.prepare('UPDATE children SET available_shells = available_shells - ? WHERE id = ?').run(count, child.id);
}
// Créditer l'activité
const newTotal = activity.current_shells + count;
const newStatus = newTotal >= activity.cost_shells ? 'UNLOCKED' : activity.status;
db.prepare('UPDATE activities SET current_shells = ?, status = ? WHERE id = ?').run(newTotal, newStatus, activity.id);
// Enregistrer la contribution
db.prepare(`
INSERT INTO activity_contributions (activity_id, child_id, shells, created_at)
VALUES (?, ?, ?, ?)
`).run(activity.id, child.id, count, now);
// Résoudre d'éventuelles requêtes Alexa PENDING pour cet enfant sur cette activité afin de déverrouiller son bouton
db.prepare(`
UPDATE pending_requests
SET status = 'APPROVED', resolved_at = ?
WHERE activity_id = ? AND child_id = ? AND status = 'PENDING'
`).run(now, activity.id, child.id);
// Enregistrer la transaction
const sourceLabel = isVirtual ? 'déduits du compte virtuel' : 'coquillages physiques remis';
db.prepare(`
INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at)
VALUES (?, 'CONTRIBUTION', ?, 0, ?, ?)
`).run(
child.id,
isVirtual ? -count : 0,
`${child.name} a mis ${count} 🐚 (${sourceLabel}) pour "${activity.title}" (${newTotal}/${activity.cost_shells})`,
now
);
res.json({
message: `${count} 🐚 attribués avec succès à "${activity.title}" pour ${child.name} !`,
activity: getEnrichedActivities().find((a) => a.id === activityId)
});
});
// 7. POST /api/requests
app.post('/api/requests', (req, res) => {
const {
@@ -701,6 +770,125 @@ app.post('/api/children/:id/adjust', (req, res) => {
res.json({ message: 'Compte mis à jour.', child: getEnrichedChildren().find((c) => c.id === id) });
});
// 13b. PUT /api/children/:id (Édition directe des soldes de l'enfant par les parents)
app.put('/api/children/:id', (req, res) => {
const id = Number(req.params.id);
const child = db.prepare('SELECT * FROM children WHERE id = ?').get(id);
if (!child) return res.status(404).json({ error: 'Enfant introuvable.' });
const { available_shells, locked_shells, euros_balance } = req.body;
const newAvailable = available_shells !== undefined ? Math.max(0, Number(available_shells)) : child.available_shells;
const newLocked = locked_shells !== undefined ? Math.max(0, Number(locked_shells)) : child.locked_shells;
const newEuros = euros_balance !== undefined ? Math.max(0, Number(euros_balance)) : child.euros_balance;
const now = new Date().toISOString();
db.prepare(`
UPDATE children
SET available_shells = ?, locked_shells = ?, euros_balance = ?
WHERE id = ?
`).run(newAvailable, newLocked, newEuros, id);
db.prepare(`
INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at)
VALUES (?, 'ADMIN_EDIT', ?, ?, ?, ?)
`).run(
id,
newAvailable - child.available_shells,
newEuros - child.euros_balance,
`Mise à jour des soldes de ${child.name} : ${newAvailable} 🐚 dispo, ${newLocked} 🔒 bloqués, ${newEuros.toFixed(2)} €`,
now
);
res.json({
message: `Soldes de ${child.name} enregistrés avec succès !`,
child: getEnrichedChildren().find((c) => c.id === id)
});
});
// 13c. POST /api/children/:id/transfer (Transfert réel <-> virtuel, blocage/déblocage rapide)
app.post('/api/children/:id/transfer', (req, res) => {
const id = Number(req.params.id);
const child = db.prepare('SELECT * FROM children WHERE id = ?').get(id);
if (!child) return res.status(404).json({ error: 'Enfant introuvable.' });
const { type, shells, reason = '' } = req.body;
const count = Number(shells);
if (!count || count <= 0) {
return res.status(400).json({ error: 'Le nombre de coquillages doit être supérieur à 0.' });
}
const now = new Date().toISOString();
switch (type) {
case 'REAL_TO_AVAILABLE': {
// Coquillages physiques reçus -> crédités sur le compte disponible
db.prepare('UPDATE children SET available_shells = available_shells + ? WHERE id = ?').run(count, id);
db.prepare(`
INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at)
VALUES (?, 'TRANSFER_REAL_AVAILABLE', ?, 0, ?, ?)
`).run(id, count, `Dépôt réel : +${count} 🐚 physiques ajoutés au compte disponible de ${child.name} (${reason || 'Remis en main propre'})`, now);
break;
}
case 'REAL_TO_LOCKED': {
// Coquillages physiques reçus -> directement bloqués sur le livret (+intérêts)
db.prepare('UPDATE children SET locked_shells = locked_shells + ? WHERE id = ?').run(count, id);
db.prepare(`
INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at)
VALUES (?, 'TRANSFER_REAL_LOCKED', ?, 0, ?, ?)
`).run(id, count, `Dépôt réel bloqué : +${count} 🔒 bloqués pour ${child.name} (${reason || 'Livret épargne'})`, now);
break;
}
case 'AVAILABLE_TO_REAL': {
// L'enfant reprend/utilise des coquillages réels -> débités du compte virtuel disponible
if (child.available_shells < count) {
return res.status(400).json({ error: `Solde insuffisant : ${child.name} n'a que ${child.available_shells} coquillages disponibles.` });
}
db.prepare('UPDATE children SET available_shells = available_shells - ? WHERE id = ?').run(count, id);
db.prepare(`
INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at)
VALUES (?, 'TRANSFER_AVAILABLE_REAL', ?, 0, ?, ?)
`).run(id, -count, `Retrait réel : -${count} 🐚 utilisés dans la vraie vie par ${child.name} (${reason || 'Utilisation réelle'})`, now);
break;
}
case 'LOCK': {
// Transfert interne : disponible -> bloqué
if (child.available_shells < count) {
return res.status(400).json({ error: `Solde disponible insuffisant (${child.available_shells} 🐚 dispo).` });
}
db.prepare('UPDATE children SET available_shells = available_shells - ?, locked_shells = locked_shells + ? WHERE id = ?').run(count, count, id);
db.prepare(`
INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at)
VALUES (?, 'LOCK_VIRTUAL', 0, 0, ?, ?)
`).run(id, `${child.name} a bloqué ${count} 🐚 vers son livret épargne`, now);
break;
}
case 'UNLOCK': {
// Transfert interne : bloqué -> disponible
if (child.locked_shells < count) {
return res.status(400).json({ error: `Solde bloqué insuffisant (${child.locked_shells} 🔒 bloqués).` });
}
db.prepare('UPDATE children SET available_shells = available_shells + ?, locked_shells = locked_shells - ? WHERE id = ?').run(count, count, id);
db.prepare(`
INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at)
VALUES (?, 'UNLOCK_VIRTUAL', 0, 0, ?, ?)
`).run(id, `${count} 🐚 bloqués reversés sur le compte disponible de ${child.name}`, now);
break;
}
default:
return res.status(400).json({ error: `Type de transfert inconnu: ${type}` });
}
res.json({
message: `Opération enregistrée pour ${child.name} !`,
child: getEnrichedChildren().find((c) => c.id === id)
});
});
// 14. POST /api/alexa/voice-intent (Avec support Personalisation Voice ID, cartes illustrées & APL Echo Show)
app.post('/api/alexa/voice-intent', (req, res) => {
const { intent, slots = {}, personId = null, personGivenName = null } = req.body;

View File

@@ -12,9 +12,9 @@ services:
- 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
# Persistance de la base SQLite et des images téléversées (Volumes nommés gérés dans Portainer)
- coquillages_data:/app/back/data
- coquillages_uploads:/app/back/uploads
networks:
- coquillages-net
@@ -36,3 +36,9 @@ networks:
coquillages-net:
driver: bridge
volumes:
coquillages_data:
name: coquillages_data
coquillages_uploads:
name: coquillages_uploads

File diff suppressed because it is too large Load Diff