Compare commits
21 Commits
c2a32f11b8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0648bf74bd | |||
| d469fc41e8 | |||
| 5af0f28b39 | |||
| fd14d20dd0 | |||
| 7d020c33b6 | |||
| 1a3ea9e0c1 | |||
| b0db6a5ddf | |||
| f13efb8707 | |||
| 75c90aa042 | |||
| 616b14edca | |||
| 42604b037b | |||
| 297dea3925 | |||
| 1f50e645ac | |||
| 56b913e888 | |||
| ce9d05cd5c | |||
| d4e6a1192e | |||
| ba0c076f70 | |||
| 39046c8d35 | |||
| bc63c56da6 | |||
| ce37fafddf | |||
| 04c4f7839f |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
# .dockerignore
|
||||
node_modules
|
||||
dist
|
||||
.tmp
|
||||
.cache
|
||||
.env
|
||||
build
|
||||
@@ -1,3 +1,3 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$HOME/.nvm/nvm.sh"
|
||||
node scripts/check-version.js
|
||||
#. "$HOME/.nvm/nvm.sh"
|
||||
#node scripts/check-version.js
|
||||
44
Dockerfile
44
Dockerfile
@@ -1,30 +1,36 @@
|
||||
# Creating multi-stage build for production
|
||||
FROM node:18-alpine AS build
|
||||
RUN apk update && apk add --no-cache build-base gcc autoconf automake zlib-dev libpng-dev vips-dev git > /dev/null 2>&1
|
||||
# --- ÉTAPE 1 : BUILD ---
|
||||
FROM node:20-alpine AS build-stage
|
||||
RUN apk update && apk add --no-cache \
|
||||
build-base gcc autoconf automake zlib-dev libpng-dev vips-dev git > /dev/null 2>&1
|
||||
|
||||
ARG NODE_ENV=production
|
||||
ENV NODE_ENV=${NODE_ENV}
|
||||
|
||||
WORKDIR /opt/
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm install -g node-gyp
|
||||
RUN npm config set fetch-retry-maxtimeout 600000 -g && npm install --only=production
|
||||
ENV PATH=/opt/node_modules/.bin:$PATH
|
||||
WORKDIR /opt/app
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Creating final production image
|
||||
FROM node:18-alpine
|
||||
RUN apk add --no-cache vips-dev
|
||||
# On force le build de l'admin explicitement pour être sûr que le dossier existe
|
||||
RUN yarn build
|
||||
|
||||
# --- ÉTAPE 2 : RUNTIME ---
|
||||
FROM node:20-alpine AS runtime-stage
|
||||
RUN apk add --no-cache vips-dev libpng libjpeg-turbo
|
||||
|
||||
ARG NODE_ENV=production
|
||||
ENV NODE_ENV=${NODE_ENV}
|
||||
WORKDIR /opt/
|
||||
COPY --from=build /opt/node_modules ./node_modules
|
||||
WORKDIR /opt/app
|
||||
COPY --from=build /opt/app ./
|
||||
ENV PATH=/opt/node_modules/.bin:$PATH
|
||||
|
||||
RUN chown -R node:node /opt/app
|
||||
WORKDIR /opt/app
|
||||
|
||||
# ASTUCE : On copie tout l'objet de build avec --chown
|
||||
# Cela évite de lister les dossiers un par un et de risquer un "Not Found"
|
||||
COPY --from=build-stage --chown=node:node /opt/app ./
|
||||
|
||||
# On expose le binaire
|
||||
ENV PATH=/opt/app/node_modules/.bin:$PATH
|
||||
|
||||
USER node
|
||||
EXPOSE 1337
|
||||
CMD ["npm", "run", "start"]
|
||||
|
||||
CMD ["yarn", "start"]
|
||||
@@ -1,57 +1,28 @@
|
||||
export default [
|
||||
"strapi::logger",
|
||||
"strapi::errors",
|
||||
//'strapi::security',
|
||||
{
|
||||
name: "strapi::security",
|
||||
config: {
|
||||
contentSecurityPolicy: {
|
||||
useDefaults: true,
|
||||
directives: {
|
||||
"connect-src": ["'self'", "https:", "http:"],
|
||||
"img-src": [
|
||||
"'self'",
|
||||
"data:",
|
||||
"blob:",
|
||||
"market-assets.strapi.io",
|
||||
"192.168.0.211:9000",
|
||||
"container.harmonylab.ovh",
|
||||
],
|
||||
"media-src": [
|
||||
"'self'",
|
||||
"data:",
|
||||
"blob:",
|
||||
"market-assets.strapi.io",
|
||||
"192.168.0.211:9000",
|
||||
"container.harmonylab.ovh",
|
||||
],
|
||||
upgradeInsecureRequests: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "strapi::cors",
|
||||
config: {
|
||||
origin: ["https://back.harmonylab.ovh", "https://www.choralsync.com"],
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
|
||||
headers: ["Content-Type", "Authorization", "Origin", "Accept"],
|
||||
keepHeadersOnError: true,
|
||||
},
|
||||
},
|
||||
"strapi::security",
|
||||
"strapi::cors",
|
||||
"strapi::poweredBy",
|
||||
"strapi::query",
|
||||
"strapi::body",
|
||||
{
|
||||
name: 'strapi::session',
|
||||
config: {
|
||||
cookie: {
|
||||
secure: true, // Force car tu es sur https://back.harmonylab.ovh
|
||||
sameSite: 'lax',
|
||||
httpOnly: true,
|
||||
name: "strapi::body",
|
||||
config: {
|
||||
includeUnparsed: true, // INDISPENSABLE pour les webhooks
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "strapi::session",
|
||||
config: {
|
||||
// Nom unique pour être sûr que le navigateur ne garde pas un vieux cookie
|
||||
key: "strapi_session_local_debug",
|
||||
maxAge: 86400000,
|
||||
httpOnly: true,
|
||||
// FORCE à false pour que ça marche en HTTP local
|
||||
secure: false,
|
||||
sameSite: "lax",
|
||||
},
|
||||
},
|
||||
},
|
||||
"strapi::favicon",
|
||||
"strapi::public",
|
||||
];
|
||||
|
||||
@@ -3,16 +3,18 @@ export default () => ({
|
||||
config: {
|
||||
provider: "nodemailer",
|
||||
providerOptions: {
|
||||
host: "mail.harmonychoral.com",
|
||||
port: 465,
|
||||
host: "smtp.zeptomail.eu",
|
||||
port: 587,
|
||||
auth: {
|
||||
user: "admin@harmonychoral.com",
|
||||
pass: "Apslxnap12bn23",
|
||||
user: "emailapikey",
|
||||
pass: "yA6KbHsJ4lrywWtTFUc+0pSC94lm/aE/2nzks3i2fpZ1LYXp3qE71RBvd4O4c2CLjdfT5a9UbIkVJoCwvIpbfpczPIBXJpTGTuv4P2uV48xh8ciEYNYjhJivALIWFqVOeBsnDyo4QfEjWA==",
|
||||
},
|
||||
debug: true,
|
||||
logger: true,
|
||||
},
|
||||
settings: {
|
||||
defaultFrom: "admin@harmonychoral.com",
|
||||
defaultReplyTo: "admin@harmonychoral.com",
|
||||
defaultFrom: "ChoralSync <noreply@choralsync.com>",
|
||||
defaultReplyTo: "contact@choralsync.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
import cronTasks from "./cron-tasks";
|
||||
|
||||
export default ({ env }) => ({
|
||||
host: env("HOST", "0.0.0.0"),
|
||||
port: env.int("PORT", 1337),
|
||||
proxy: true,
|
||||
url: env('STRAPI_URL'),
|
||||
app: {
|
||||
keys: env.array("APP_KEYS"),
|
||||
},
|
||||
cron: {
|
||||
enabled: true,
|
||||
tasks: cronTasks,
|
||||
},
|
||||
admin: {
|
||||
auth: {
|
||||
events: {
|
||||
onConnectionSuccess(e) {
|
||||
console.log('Login success for:', e.user.email);
|
||||
},
|
||||
export default ({ env }) => {
|
||||
// DEBUG: On vérifie ce que Strapi reçoit vraiment
|
||||
//const appKeys = env.array("APP_KEYS");
|
||||
const appKeys = env.array("APP_KEYS", ["defaultKey1", "defaultKey2"]);
|
||||
console.log("------------------------------------------------");
|
||||
console.log("Key Check:", appKeys.length > 0 ? "CHARGÉES ✅" : "VIDES ❌");
|
||||
console.log("Key Type:", typeof appKeys[0]); // Doit afficher 'string'
|
||||
console.log("Host:", env("HOST", "0.0.0.0"));
|
||||
console.log("------------------------------------------------");
|
||||
return {
|
||||
host: env("HOST", "0.0.0.0"),
|
||||
port: env.int("PORT", 1337),
|
||||
url: env("STRAPI_URL", "http://localhost:1337"),
|
||||
app: {
|
||||
// ON FORCE UN TABLEAU EN DUR ICI :
|
||||
keys: [
|
||||
"UneSuperClefSecreteTresLongueEtAleatoire123",
|
||||
"UneAutreClefDeSecours456",
|
||||
],
|
||||
},
|
||||
cron: {
|
||||
enabled: true,
|
||||
tasks: cronTasks,
|
||||
},
|
||||
proxy: false,
|
||||
admin: {
|
||||
auth: {
|
||||
secret: env("ADMIN_JWT_SECRET"),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
24577
package-lock.json
generated
24577
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "harmony-back",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.9",
|
||||
"private": true,
|
||||
"description": "A Strapi application",
|
||||
"scripts": {
|
||||
@@ -26,9 +26,11 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"strapi-v5-plugin-populate-deep": "^4.0.5",
|
||||
"stripe": "^20.4.0",
|
||||
"styled-components": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/minimatch": "^6.0.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
|
||||
@@ -58,14 +58,15 @@
|
||||
"type": "text"
|
||||
},
|
||||
"medias": {
|
||||
"type": "media",
|
||||
"multiple": true,
|
||||
"required": false,
|
||||
"allowedTypes": [
|
||||
"images",
|
||||
"files",
|
||||
"videos",
|
||||
"audios"
|
||||
],
|
||||
"type": "media",
|
||||
"multiple": true
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,29 @@
|
||||
"type": "component",
|
||||
"repeatable": true,
|
||||
"component": "user.permissions"
|
||||
},
|
||||
"role_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"permission_exceptions": {
|
||||
"type": "json"
|
||||
},
|
||||
"state": {
|
||||
"type": "enumeration",
|
||||
"enum": [
|
||||
"active",
|
||||
"pending_user_approval",
|
||||
"pending_admin_approval",
|
||||
"decline"
|
||||
]
|
||||
},
|
||||
"invite_email": {
|
||||
"type": "email",
|
||||
"private": true
|
||||
},
|
||||
"invite_token": {
|
||||
"type": "string",
|
||||
"private": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,97 @@
|
||||
/**
|
||||
* choral-membership controller
|
||||
*/
|
||||
import { factories } from "@strapi/strapi";
|
||||
import crypto from "crypto";
|
||||
|
||||
import { factories } from '@strapi/strapi'
|
||||
export default factories.createCoreController(
|
||||
"api::choral-membership.choral-membership",
|
||||
({ strapi }) => ({
|
||||
async create(ctx) {
|
||||
const { data } = ctx.request.body;
|
||||
const inviter = ctx.state.user;
|
||||
|
||||
export default factories.createCoreController('api::choral-membership.choral-membership');
|
||||
// 1. Déterminer le type d'invitation
|
||||
const isExternalInvite = !!data.invite_email && !data.user;
|
||||
const isInternalInvite = !!data.user;
|
||||
|
||||
let token = null;
|
||||
let targetEmail = null;
|
||||
|
||||
// 2. Configuration commune
|
||||
// On force le statut "en attente" peut importe le type d'invité
|
||||
ctx.request.body.data.state = "pending_user_approval";
|
||||
if (!data.role) {
|
||||
ctx.request.body.data.role = "member";
|
||||
}
|
||||
|
||||
// 3. Traitement spécifique selon le cas
|
||||
if (isExternalInvite) {
|
||||
// Cas A : Utilisateur externe (Token requis)
|
||||
token = crypto.randomBytes(32).toString("hex");
|
||||
ctx.request.body.data.invite_token = token;
|
||||
ctx.request.body.data.invite_email = data.invite_email.toLowerCase();
|
||||
targetEmail = data.invite_email.toLowerCase();
|
||||
} else if (isInternalInvite) {
|
||||
// Cas B : Utilisateur existant (Pas de token)
|
||||
// On s'assure de nettoyer les champs d'invitation externe au cas où le front les enverrait par erreur
|
||||
ctx.request.body.data.invite_token = null;
|
||||
ctx.request.body.data.invite_email = null;
|
||||
|
||||
// On récupère l'email de l'utilisateur existant pour lui envoyer une notification
|
||||
const invitedUser = await strapi
|
||||
.documents("plugin::users-permissions.user")
|
||||
.findMany({
|
||||
filters: {
|
||||
id: data.user,
|
||||
},
|
||||
});
|
||||
if (invitedUser && invitedUser.length > 0) {
|
||||
targetEmail = invitedUser[0].email;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Exécution de la création native par Strapi
|
||||
const response = await super.create(ctx);
|
||||
|
||||
// 5. Post-traitement : Envoi de l'email
|
||||
if (response && targetEmail) {
|
||||
try {
|
||||
const chorals = await strapi
|
||||
.documents("api::choral.choral")
|
||||
.findMany({
|
||||
filters: {
|
||||
id: data.choral,
|
||||
},
|
||||
});
|
||||
const choral = chorals[0];
|
||||
|
||||
if (isExternalInvite) {
|
||||
// Email d'invitation avec le lien tokenisé
|
||||
await strapi
|
||||
.service("api::mails.mails")
|
||||
.sendInvitation(
|
||||
targetEmail,
|
||||
inviter?.username || "Un membre",
|
||||
choral?.name || "votre chorale",
|
||||
token,
|
||||
);
|
||||
} else if (isInternalInvite) {
|
||||
// Email de notification pour un utilisateur existant
|
||||
// Ici le token est 'null', on prévient le service que c'est un user interne
|
||||
await strapi.service("api::mails.mails").sendInvitation(
|
||||
targetEmail,
|
||||
inviter?.username || "Un membre",
|
||||
choral?.name || "votre chorale",
|
||||
null, // On passe explicitement null
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Erreur lors de l'envoi de l'email d'invitation :",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -100,6 +100,31 @@
|
||||
"relation": "oneToMany",
|
||||
"target": "api::channel.channel",
|
||||
"mappedBy": "choral"
|
||||
},
|
||||
"available_tags": {
|
||||
"type": "json"
|
||||
},
|
||||
"social_networks": {
|
||||
"type": "json"
|
||||
},
|
||||
"description": {
|
||||
"type": "text"
|
||||
},
|
||||
"founded": {
|
||||
"type": "integer"
|
||||
},
|
||||
"available_roles": {
|
||||
"type": "json"
|
||||
},
|
||||
"logo": {
|
||||
"allowedTypes": [
|
||||
"images",
|
||||
"files",
|
||||
"videos",
|
||||
"audios"
|
||||
],
|
||||
"type": "media",
|
||||
"multiple": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,5 +33,5 @@ export default factories.createCoreController(
|
||||
const result = await super.create(ctx);
|
||||
return result;
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"kind": "collectionType",
|
||||
"collectionName": "form_templates",
|
||||
"info": {
|
||||
"singularName": "form-template",
|
||||
"pluralName": "form-templates",
|
||||
"displayName": "FormTemplate"
|
||||
},
|
||||
"options": {
|
||||
"draftAndPublish": false
|
||||
},
|
||||
"pluginOptions": {},
|
||||
"attributes": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
"description": {
|
||||
"type": "text"
|
||||
},
|
||||
"state": {
|
||||
"type": "enumeration",
|
||||
"enum": [
|
||||
"draft",
|
||||
"published",
|
||||
"archived"
|
||||
],
|
||||
"default": "draft"
|
||||
},
|
||||
"original_file": {
|
||||
"allowedTypes": [
|
||||
"images",
|
||||
"files",
|
||||
"videos",
|
||||
"audios"
|
||||
],
|
||||
"type": "media",
|
||||
"multiple": false
|
||||
},
|
||||
"schema": {
|
||||
"type": "json"
|
||||
},
|
||||
"target_tags": {
|
||||
"type": "json"
|
||||
},
|
||||
"choral": {
|
||||
"type": "relation",
|
||||
"relation": "oneToOne",
|
||||
"target": "api::choral.choral"
|
||||
},
|
||||
"author": {
|
||||
"type": "relation",
|
||||
"relation": "oneToOne",
|
||||
"target": "plugin::users-permissions.user"
|
||||
}
|
||||
}
|
||||
}
|
||||
7
src/api/form-template/controllers/form-template.ts
Normal file
7
src/api/form-template/controllers/form-template.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* form-template controller
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi'
|
||||
|
||||
export default factories.createCoreController('api::form-template.form-template');
|
||||
7
src/api/form-template/routes/form-template.ts
Normal file
7
src/api/form-template/routes/form-template.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* form-template router
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreRouter('api::form-template.form-template');
|
||||
7
src/api/form-template/services/form-template.ts
Normal file
7
src/api/form-template/services/form-template.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* form-template service
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreService('api::form-template.form-template');
|
||||
@@ -25,6 +25,16 @@
|
||||
}
|
||||
},
|
||||
"component": "mail.mail"
|
||||
},
|
||||
"onInviteUser": {
|
||||
"type": "component",
|
||||
"repeatable": true,
|
||||
"pluginOptions": {
|
||||
"i18n": {
|
||||
"localized": true
|
||||
}
|
||||
},
|
||||
"component": "mail.mail"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,61 @@
|
||||
/**
|
||||
* mails service
|
||||
*/
|
||||
import { factories } from "@strapi/strapi";
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
export default factories.createCoreService(
|
||||
"api::mails.mails",
|
||||
({ strapi }) => ({
|
||||
// Notre méthode personnalisée
|
||||
async sendInvitation(
|
||||
emailAddress: string,
|
||||
inviterName: string,
|
||||
choirName: string,
|
||||
inviteToken: string | null,
|
||||
) {
|
||||
// 1. Définir le lien de redirection selon le type d'utilisateur
|
||||
// S'il y a un token, c'est un nouvel utilisateur. Sinon, c'est un membre existant.
|
||||
const inviteLink = inviteToken
|
||||
? `https://www.choralsync.com/invite?token=${inviteToken}`
|
||||
: `https://www.choralsync.com/app`; // Lien vers l'app pour accepter l'invitation interne
|
||||
|
||||
export default factories.createCoreService('api::mails.mails');
|
||||
// 2. Récupérer le single-type "mails" avec l'API Document
|
||||
const mailsConfig = await strapi.documents("api::mails.mails").findFirst({
|
||||
populate: ["onInviteUser"],
|
||||
});
|
||||
|
||||
if (!mailsConfig || !mailsConfig.onInviteUser) {
|
||||
throw new Error(
|
||||
"Le template d'email d'invitation n'est pas configuré dans le CMS.",
|
||||
);
|
||||
}
|
||||
|
||||
const templateConfig = mailsConfig.onInviteUser[0];
|
||||
|
||||
try {
|
||||
// 3. Envoyer l'email via le moteur de template natif
|
||||
await strapi.plugin("email").service("email").sendTemplatedEmail(
|
||||
{
|
||||
to: emailAddress,
|
||||
from: "ChoralSync <contact@choralsync.com>",
|
||||
},
|
||||
{
|
||||
subject: templateConfig.subject,
|
||||
html: templateConfig.message,
|
||||
text: `Bonjour, Vous avez été invité(e) par <%= inviter_name %> à rejoindre <%= choir_name %>. Acceptez l'invitation en cliquant sur ce lien : <%= invite_link %>`,
|
||||
},
|
||||
{
|
||||
// Variables injectées dans le template (<%= variable %>)
|
||||
inviter_name: inviterName,
|
||||
choir_name: choirName,
|
||||
invite_link: inviteLink,
|
||||
},
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de l'envoi de l'email :", error);
|
||||
// On throw l'erreur pour que le contrôleur puisse la logger,
|
||||
// mais idéalement sans bloquer la création en base.
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
32
src/api/order/content-types/order/schema.json
Normal file
32
src/api/order/content-types/order/schema.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"kind": "collectionType",
|
||||
"collectionName": "orders",
|
||||
"info": {
|
||||
"singularName": "order",
|
||||
"pluralName": "orders",
|
||||
"displayName": "Order",
|
||||
"description": ""
|
||||
},
|
||||
"options": {
|
||||
"draftAndPublish": false
|
||||
},
|
||||
"pluginOptions": {},
|
||||
"attributes": {
|
||||
"stripeId": {
|
||||
"type": "string",
|
||||
"unique": true
|
||||
},
|
||||
"amount": {
|
||||
"type": "decimal"
|
||||
},
|
||||
"planType": {
|
||||
"type": "string"
|
||||
},
|
||||
"user": {
|
||||
"type": "relation",
|
||||
"relation": "manyToOne",
|
||||
"target": "plugin::users-permissions.user",
|
||||
"inversedBy": "orders"
|
||||
}
|
||||
}
|
||||
}
|
||||
75
src/api/order/controllers/order.ts
Normal file
75
src/api/order/controllers/order.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { factories } from "@strapi/strapi";
|
||||
import Stripe from "stripe";
|
||||
|
||||
export default factories.createCoreController(
|
||||
"api::order.order" as any,
|
||||
({ strapi }) => ({
|
||||
async handleWebhook(ctx) {
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
||||
const sig = ctx.request.headers["stripe-signature"];
|
||||
const unparsedBody = ctx.request.body[Symbol.for("unparsedBody")];
|
||||
|
||||
let event: Stripe.Event;
|
||||
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(
|
||||
unparsedBody,
|
||||
sig as string,
|
||||
process.env.STRIPE_WEBHOOK_SECRET!,
|
||||
);
|
||||
} catch (err: any) {
|
||||
strapi.log.error(`❌ Erreur de signature Webhook: ${err.message}`);
|
||||
return ctx.badRequest(`Webhook Error: ${err.message}`);
|
||||
}
|
||||
|
||||
// IMPORTANT : On logue le type d'événement reçu pour débugger
|
||||
strapi.log.info(`📩 Événement reçu : ${event.type}`);
|
||||
|
||||
// On ne traite QUE le checkout.session.completed
|
||||
if (event.type === "checkout.session.completed") {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
const userId = session.metadata?.userId;
|
||||
const planType = session.metadata?.planType as
|
||||
| "free"
|
||||
| "pro"
|
||||
| "premium";
|
||||
|
||||
if (userId) {
|
||||
try {
|
||||
// Création de la commande
|
||||
await strapi.documents("api::order.order").create({
|
||||
data: {
|
||||
stripeId: session.id,
|
||||
amount: session.amount_total ? session.amount_total / 100 : 0,
|
||||
planType: planType,
|
||||
user: userId,
|
||||
},
|
||||
});
|
||||
|
||||
// Mise à jour de l'utilisateur
|
||||
await strapi.documents("plugin::users-permissions.user").update({
|
||||
documentId: userId,
|
||||
data: {
|
||||
isPremium: true,
|
||||
subscriptionPlan: planType,
|
||||
stripeCustomerId: session.customer as string,
|
||||
subscriptionId: session.subscription as string,
|
||||
trialStartedAt: null,
|
||||
},
|
||||
});
|
||||
strapi.log.info(
|
||||
`✅ Droits mis à jour pour l'utilisateur ${userId}`,
|
||||
);
|
||||
} catch (dbError) {
|
||||
strapi.log.error(`❌ Erreur base de données : ${dbError.message}`);
|
||||
// On répond quand même 200 à Stripe pour éviter les retries infinis
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// INDISPENSABLE : Répondre 200 OK à Stripe pour TOUS les événements
|
||||
// Cela évite que Stripe ne renvoie la requête 10 fois
|
||||
ctx.send({ received: true });
|
||||
},
|
||||
}),
|
||||
);
|
||||
24
src/api/order/routes/order.ts
Normal file
24
src/api/order/routes/order.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export default {
|
||||
routes: [
|
||||
// La route pour le Webhook Stripe
|
||||
{
|
||||
method: "POST",
|
||||
path: "/stripe/webhook",
|
||||
handler: "api::order.order.handleWebhook",
|
||||
config: {
|
||||
auth: false,
|
||||
},
|
||||
},
|
||||
// Les routes automatiques (find, findOne, create, etc.)
|
||||
{
|
||||
method: "GET",
|
||||
path: "/orders",
|
||||
handler: "api::order.order.find",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/orders/:id",
|
||||
handler: "api::order.order.findOne",
|
||||
},
|
||||
],
|
||||
};
|
||||
7
src/api/order/services/order.ts
Normal file
7
src/api/order/services/order.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* order service
|
||||
*/
|
||||
|
||||
import { factories } from '@strapi/strapi';
|
||||
|
||||
export default factories.createCoreService('api::order.order');
|
||||
File diff suppressed because it is too large
Load Diff
@@ -242,6 +242,31 @@
|
||||
},
|
||||
"education": {
|
||||
"type": "string"
|
||||
},
|
||||
"subscriptionPlan": {
|
||||
"type": "enumeration",
|
||||
"enum": [
|
||||
"free",
|
||||
"pro",
|
||||
"premium"
|
||||
],
|
||||
"default": "free"
|
||||
},
|
||||
"maxChoirs": {
|
||||
"type": "integer",
|
||||
"default": 0
|
||||
},
|
||||
"trialStartedAt": {
|
||||
"type": "date"
|
||||
},
|
||||
"orders": {
|
||||
"type": "relation",
|
||||
"relation": "oneToMany",
|
||||
"target": "api::order.order",
|
||||
"mappedBy": "user"
|
||||
},
|
||||
"smartTags": {
|
||||
"type": "json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10
src/index.ts
10
src/index.ts
@@ -1,13 +1,21 @@
|
||||
import type { Core } from "@strapi/strapi";
|
||||
|
||||
export default {
|
||||
|
||||
/**
|
||||
* An asynchronous register function that runs before
|
||||
* your application is initialized.
|
||||
*
|
||||
* This gives you an opportunity to extend code.
|
||||
*/
|
||||
register(/* { strapi }: { strapi: Core.Strapi } */) {},
|
||||
register( { strapi }: { strapi: Core.Strapi } ) {
|
||||
strapi.server.use(async (ctx, next) => {
|
||||
if (ctx.req?.socket) {
|
||||
(ctx.req.socket as any).encrypted = true;
|
||||
}
|
||||
await next();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* An asynchronous bootstrap function that runs before
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"noImplicitThis": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"sourceMap": true
|
||||
"sourceMap": true,
|
||||
"types": ["node", "react", "react-dom"]
|
||||
},
|
||||
"include": [
|
||||
// Include root files
|
||||
|
||||
101
types/generated/contentTypes.d.ts
vendored
101
types/generated/contentTypes.d.ts
vendored
@@ -723,15 +723,22 @@ export interface ApiChoralMembershipChoralMembership
|
||||
createdAt: Schema.Attribute.DateTime;
|
||||
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
||||
Schema.Attribute.Private;
|
||||
invite_email: Schema.Attribute.Email & Schema.Attribute.Private;
|
||||
invite_token: Schema.Attribute.String & Schema.Attribute.Private;
|
||||
locale: Schema.Attribute.String & Schema.Attribute.Private;
|
||||
localizations: Schema.Attribute.Relation<
|
||||
'oneToMany',
|
||||
'api::choral-membership.choral-membership'
|
||||
> &
|
||||
Schema.Attribute.Private;
|
||||
permission_exceptions: Schema.Attribute.JSON;
|
||||
permissions: Schema.Attribute.Component<'user.permissions', true>;
|
||||
publishedAt: Schema.Attribute.DateTime;
|
||||
role: Schema.Attribute.Enumeration<['member', 'admin', 'owner']>;
|
||||
role_id: Schema.Attribute.String;
|
||||
state: Schema.Attribute.Enumeration<
|
||||
['active', 'pending_user_approval', 'pending_admin_approval', 'decline']
|
||||
>;
|
||||
updatedAt: Schema.Attribute.DateTime;
|
||||
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
||||
Schema.Attribute.Private;
|
||||
@@ -799,6 +806,8 @@ export interface ApiChoralChoral extends Struct.CollectionTypeSchema {
|
||||
'oneToMany',
|
||||
'api::announcement.announcement'
|
||||
>;
|
||||
available_roles: Schema.Attribute.JSON;
|
||||
available_tags: Schema.Attribute.JSON;
|
||||
boards: Schema.Attribute.Relation<'oneToMany', 'api::board.board'>;
|
||||
calendar: Schema.Attribute.Relation<'oneToMany', 'api::event.event'>;
|
||||
channels: Schema.Attribute.Relation<'oneToMany', 'api::channel.channel'>;
|
||||
@@ -808,13 +817,16 @@ export interface ApiChoralChoral extends Struct.CollectionTypeSchema {
|
||||
createdAt: Schema.Attribute.DateTime;
|
||||
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
||||
Schema.Attribute.Private;
|
||||
description: Schema.Attribute.Text;
|
||||
email: Schema.Attribute.Email;
|
||||
founded: Schema.Attribute.Integer;
|
||||
locale: Schema.Attribute.String & Schema.Attribute.Private;
|
||||
localizations: Schema.Attribute.Relation<
|
||||
'oneToMany',
|
||||
'api::choral.choral'
|
||||
> &
|
||||
Schema.Attribute.Private;
|
||||
logo: Schema.Attribute.Media<'images' | 'files' | 'videos' | 'audios'>;
|
||||
memberships: Schema.Attribute.Relation<
|
||||
'oneToMany',
|
||||
'api::choral-membership.choral-membership'
|
||||
@@ -831,6 +843,7 @@ export interface ApiChoralChoral extends Struct.CollectionTypeSchema {
|
||||
phoneNumber: Schema.Attribute.String;
|
||||
postal: Schema.Attribute.Integer;
|
||||
publishedAt: Schema.Attribute.DateTime;
|
||||
social_networks: Schema.Attribute.JSON;
|
||||
updatedAt: Schema.Attribute.DateTime;
|
||||
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
||||
Schema.Attribute.Private;
|
||||
@@ -1144,6 +1157,48 @@ export interface ApiEventEvent extends Struct.CollectionTypeSchema {
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApiFormTemplateFormTemplate
|
||||
extends Struct.CollectionTypeSchema {
|
||||
collectionName: 'form_templates';
|
||||
info: {
|
||||
displayName: 'FormTemplate';
|
||||
pluralName: 'form-templates';
|
||||
singularName: 'form-template';
|
||||
};
|
||||
options: {
|
||||
draftAndPublish: false;
|
||||
};
|
||||
attributes: {
|
||||
author: Schema.Attribute.Relation<
|
||||
'oneToOne',
|
||||
'plugin::users-permissions.user'
|
||||
>;
|
||||
choral: Schema.Attribute.Relation<'oneToOne', 'api::choral.choral'>;
|
||||
createdAt: Schema.Attribute.DateTime;
|
||||
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
||||
Schema.Attribute.Private;
|
||||
description: Schema.Attribute.Text;
|
||||
locale: Schema.Attribute.String & Schema.Attribute.Private;
|
||||
localizations: Schema.Attribute.Relation<
|
||||
'oneToMany',
|
||||
'api::form-template.form-template'
|
||||
> &
|
||||
Schema.Attribute.Private;
|
||||
original_file: Schema.Attribute.Media<
|
||||
'images' | 'files' | 'videos' | 'audios'
|
||||
>;
|
||||
publishedAt: Schema.Attribute.DateTime;
|
||||
schema: Schema.Attribute.JSON;
|
||||
state: Schema.Attribute.Enumeration<['draft', 'published', 'archived']> &
|
||||
Schema.Attribute.DefaultTo<'draft'>;
|
||||
target_tags: Schema.Attribute.JSON;
|
||||
title: Schema.Attribute.String & Schema.Attribute.Required;
|
||||
updatedAt: Schema.Attribute.DateTime;
|
||||
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
||||
Schema.Attribute.Private;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApiGroupMembershipGroupMembership
|
||||
extends Struct.CollectionTypeSchema {
|
||||
collectionName: 'group_memberships';
|
||||
@@ -1316,6 +1371,12 @@ export interface ApiMailsMails extends Struct.SingleTypeSchema {
|
||||
localized: true;
|
||||
};
|
||||
}>;
|
||||
onInviteUser: Schema.Attribute.Component<'mail.mail', true> &
|
||||
Schema.Attribute.SetPluginOptions<{
|
||||
i18n: {
|
||||
localized: true;
|
||||
};
|
||||
}>;
|
||||
publishedAt: Schema.Attribute.DateTime;
|
||||
updatedAt: Schema.Attribute.DateTime;
|
||||
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
||||
@@ -1400,6 +1461,38 @@ export interface ApiNotificationNotification
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApiOrderOrder extends Struct.CollectionTypeSchema {
|
||||
collectionName: 'orders';
|
||||
info: {
|
||||
description: '';
|
||||
displayName: 'Order';
|
||||
pluralName: 'orders';
|
||||
singularName: 'order';
|
||||
};
|
||||
options: {
|
||||
draftAndPublish: false;
|
||||
};
|
||||
attributes: {
|
||||
amount: Schema.Attribute.Decimal;
|
||||
createdAt: Schema.Attribute.DateTime;
|
||||
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
||||
Schema.Attribute.Private;
|
||||
locale: Schema.Attribute.String & Schema.Attribute.Private;
|
||||
localizations: Schema.Attribute.Relation<'oneToMany', 'api::order.order'> &
|
||||
Schema.Attribute.Private;
|
||||
planType: Schema.Attribute.String;
|
||||
publishedAt: Schema.Attribute.DateTime;
|
||||
stripeId: Schema.Attribute.String & Schema.Attribute.Unique;
|
||||
updatedAt: Schema.Attribute.DateTime;
|
||||
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
||||
Schema.Attribute.Private;
|
||||
user: Schema.Attribute.Relation<
|
||||
'manyToOne',
|
||||
'plugin::users-permissions.user'
|
||||
>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApiPagePage extends Struct.CollectionTypeSchema {
|
||||
collectionName: 'pages';
|
||||
info: {
|
||||
@@ -2113,7 +2206,9 @@ export interface PluginUsersPermissionsUser
|
||||
'plugin::users-permissions.user'
|
||||
> &
|
||||
Schema.Attribute.Private;
|
||||
maxChoirs: Schema.Attribute.Integer & Schema.Attribute.DefaultTo<0>;
|
||||
name: Schema.Attribute.String;
|
||||
orders: Schema.Attribute.Relation<'oneToMany', 'api::order.order'>;
|
||||
parameter: Schema.Attribute.Component<'configuration.parameter', false>;
|
||||
password: Schema.Attribute.Password &
|
||||
Schema.Attribute.Private &
|
||||
@@ -2138,8 +2233,12 @@ export interface PluginUsersPermissionsUser
|
||||
'plugin::users-permissions.role'
|
||||
>;
|
||||
saved_posts: Schema.Attribute.Relation<'oneToMany', 'api::post.post'>;
|
||||
smartTags: Schema.Attribute.JSON;
|
||||
subscriptionPlan: Schema.Attribute.Enumeration<['free', 'pro', 'premium']> &
|
||||
Schema.Attribute.DefaultTo<'free'>;
|
||||
surname: Schema.Attribute.String;
|
||||
tags: Schema.Attribute.JSON;
|
||||
trialStartedAt: Schema.Attribute.Date;
|
||||
updatedAt: Schema.Attribute.DateTime;
|
||||
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
||||
Schema.Attribute.Private;
|
||||
@@ -2194,6 +2293,7 @@ declare module '@strapi/strapi' {
|
||||
'api::direct-message.direct-message': ApiDirectMessageDirectMessage;
|
||||
'api::event-relationship.event-relationship': ApiEventRelationshipEventRelationship;
|
||||
'api::event.event': ApiEventEvent;
|
||||
'api::form-template.form-template': ApiFormTemplateFormTemplate;
|
||||
'api::group-membership.group-membership': ApiGroupMembershipGroupMembership;
|
||||
'api::group.group': ApiGroupGroup;
|
||||
'api::invite.invite': ApiInviteInvite;
|
||||
@@ -2201,6 +2301,7 @@ declare module '@strapi/strapi' {
|
||||
'api::mails.mails': ApiMailsMails;
|
||||
'api::message.message': ApiMessageMessage;
|
||||
'api::notification.notification': ApiNotificationNotification;
|
||||
'api::order.order': ApiOrderOrder;
|
||||
'api::page.page': ApiPagePage;
|
||||
'api::permissions-template.permissions-template': ApiPermissionsTemplatePermissionsTemplate;
|
||||
'api::post-ownership.post-ownership': ApiPostOwnershipPostOwnership;
|
||||
|
||||
Reference in New Issue
Block a user