Compare commits

..

4 Commits

Author SHA1 Message Date
f7f0ee17d7 0.13.10 : add fiels on message and channel
All checks were successful
Build release Docker image / Build Docker Images (push) Successful in 10m36s
2026-04-14 23:06:41 +02:00
0648bf74bd 0.13.9 : change smartTags name
Some checks failed
Build release Docker image / Build Docker Images (push) Failing after 10m34s
2026-04-13 21:56:07 +02:00
d469fc41e8 0.13.8 : invite by mail or contact and change mail provider on local
All checks were successful
Build release Docker image / Build Docker Images (push) Successful in 10m42s
2026-04-07 22:15:59 +02:00
5af0f28b39 Fix controller to remove upload
All checks were successful
Build release Docker image / Build Docker Images (push) Successful in 5m43s
2026-04-06 16:42:54 +02:00
14 changed files with 1352 additions and 83 deletions

View File

@@ -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",
},
},
},

View File

@@ -1,6 +1,6 @@
{
"name": "harmony-back",
"version": "0.13.7",
"version": "0.13.10",
"private": true,
"description": "A Strapi application",
"scripts": {

View File

@@ -4,7 +4,8 @@
"info": {
"singularName": "channel",
"pluralName": "channels",
"displayName": "Channel"
"displayName": "Channel",
"description": ""
},
"options": {
"draftAndPublish": false
@@ -33,6 +34,12 @@
"relation": "oneToMany",
"target": "api::message.message",
"mappedBy": "channel"
},
"description": {
"type": "text"
},
"permissions": {
"type": "json"
}
}
}

View File

@@ -51,6 +51,14 @@
"pending_admin_approval",
"decline"
]
},
"invite_email": {
"type": "email",
"private": true
},
"invite_token": {
"type": "string",
"private": true
}
}
}

View File

@@ -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;
},
}),
);

View File

@@ -33,34 +33,5 @@ export default factories.createCoreController(
const result = await super.create(ctx);
return result;
},
async update(ctx) {
const data = ctx.request.body.data
? JSON.parse(ctx.request.body.data)
: {};
if (ctx.request.files && ctx.request.files.coverImage) {
const files = Array.isArray(ctx.request.files.coverImage)
? ctx.request.files.coverImage[0]
: ctx.request.files.coverImage;
const extension = files.originalFilename.match(/\.[0-9a-z]+$/i);
const payload = {
fileInfo: {
caption: "undefined",
alternativeText: data.name || "",
name: `${data.name}_cover${extension}`,
},
};
const asset = await strapi.services["plugin::upload.upload"].upload({
data: payload,
files,
});
data.cover = asset[0].id;
}
ctx.request.body = { data };
const result = await super.update(ctx);
return result;
},
})
}),
);

View File

@@ -25,6 +25,16 @@
}
},
"component": "mail.mail"
},
"onInviteUser": {
"type": "component",
"repeatable": true,
"pluginOptions": {
"i18n": {
"localized": true
}
},
"component": "mail.mail"
}
}
}

View File

@@ -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;
}
},
}),
);

View File

@@ -33,6 +33,14 @@
"type": "relation",
"relation": "oneToOne",
"target": "api::choral.choral"
},
"reactions": {
"type": "json"
},
"parent_message": {
"type": "relation",
"relation": "oneToOne",
"target": "api::message.message"
}
}
}

View File

@@ -4,11 +4,134 @@
import { factories } from "@strapi/strapi";
interface ChannelPermissions {
[roleName: string]: {
read: boolean;
write: boolean;
};
}
interface PopulatedChannel {
id: any;
permissions?: any;
choral?: {
id: any;
available_roles?: any[];
};
}
interface ChoralMembership {
id: any;
role_id?: string;
}
export default factories.createCoreController(
"api::message.message",
({ strapi }) => ({
async create(ctx) {
return super.create(ctx);
const { channel: channelId } = ctx.request.body.data;
const user = ctx.state.user;
// 1. Récupérer les permissions du channel et son choral
const channel = (await strapi.entityService.findOne(
"api::channel.channel",
channelId,
{ populate: ["choral"] },
)) as unknown as PopulatedChannel;
if (!channel || !channel.choral) {
return ctx.badRequest("Canal ou chorale introuvable.");
}
// 2. Récupérer le membership du user pour cette chorale
const memberships = (await strapi.entityService.findMany(
"api::choral-membership.choral-membership",
{
filters: {
user: user.id,
choral: channel.choral.id,
},
})
},
)) as unknown as ChoralMembership[];
const membership = memberships?.[0];
const userRoleId = membership?.role_id;
// Résolution du nom du rôle à partir des available_roles de la chorale
const availableRoles = channel.choral.available_roles || [];
const userRoleName = availableRoles.find(
(r) => String(r.id) === String(userRoleId),
)?.name;
// 3. Vérification "Write" (on vérifie le flag write pour le rôle spécifique)
const permissions = channel.permissions as unknown as ChannelPermissions;
const canWrite =
permissions?.[userRoleName]?.write === true ||
userRoleId === "admin" ||
userRoleName === "Admin";
if (!canWrite) {
return ctx.forbidden(
"Désolé, tu n'as pas le droit d'écrire dans ce salon.",
);
}
// 4. Procéder à la création si OK
return await super.create(ctx);
},
async addReaction(ctx) {
const { id } = ctx.params;
const { reaction } = ctx.request.body;
const user = ctx.state.user;
if (!reaction) {
return ctx.badRequest("La réaction est requise.");
}
// 1. Récupérer le message
const message = await strapi.entityService.findOne(
"api::message.message",
id
);
if (!message) {
return ctx.notFound("Message introuvable.");
}
// 2. Gérer les réactions (toggle logic)
let reactions = (message.reactions as any) || {};
if (typeof reactions !== "object") {
reactions = {};
}
if (!reactions[reaction]) {
reactions[reaction] = [user.id];
} else {
const index = reactions[reaction].indexOf(user.id);
if (index > -1) {
// Si l'utilisateur a déjà réagi, on retire sa réaction
reactions[reaction].splice(index, 1);
// Si plus personne n'a réagi avec cet emoji, on retire l'entrée
if (reactions[reaction].length === 0) {
delete reactions[reaction];
}
} else {
// Sinon on ajoute l'utilisateur
reactions[reaction].push(user.id);
}
}
// 3. Mettre à jour le message
const updatedMessage = await strapi.entityService.update(
"api::message.message",
id,
{
data: { reactions },
}
);
return updatedMessage;
},
}),
);

View File

@@ -0,0 +1,9 @@
export default {
routes: [
{
method: "POST",
path: "/messages/:id/react",
handler: "message.addReaction",
},
],
};

View File

@@ -265,7 +265,7 @@
"target": "api::order.order",
"mappedBy": "user"
},
"admin_tags": {
"smartTags": {
"type": "json"
}
}

View File

@@ -560,6 +560,7 @@ export interface ApiBoardBoard extends Struct.CollectionTypeSchema {
export interface ApiChannelChannel extends Struct.CollectionTypeSchema {
collectionName: 'channels';
info: {
description: '';
displayName: 'Channel';
pluralName: 'channels';
singularName: 'channel';
@@ -572,6 +573,7 @@ export interface ApiChannelChannel extends Struct.CollectionTypeSchema {
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',
@@ -580,6 +582,7 @@ export interface ApiChannelChannel extends Struct.CollectionTypeSchema {
Schema.Attribute.Private;
messages: Schema.Attribute.Relation<'oneToMany', 'api::message.message'>;
name: Schema.Attribute.String;
permissions: Schema.Attribute.JSON;
publishedAt: Schema.Attribute.DateTime;
type: Schema.Attribute.Enumeration<['TEXT', 'AUDIO', 'VIDEO']>;
updatedAt: Schema.Attribute.DateTime;
@@ -723,6 +726,8 @@ 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',
@@ -1369,6 +1374,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'> &
@@ -1405,7 +1416,12 @@ export interface ApiMessageMessage extends Struct.CollectionTypeSchema {
'api::message.message'
> &
Schema.Attribute.Private;
parent_message: Schema.Attribute.Relation<
'oneToOne',
'api::message.message'
>;
publishedAt: Schema.Attribute.DateTime;
reactions: Schema.Attribute.JSON;
updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private;
@@ -2132,7 +2148,6 @@ export interface PluginUsersPermissionsUser
attributes: {
activities: Schema.Attribute.Component<'group.activity', true>;
address: Schema.Attribute.Text;
admin_tags: Schema.Attribute.JSON;
announcements: Schema.Attribute.Relation<
'oneToMany',
'api::announcement.announcement'
@@ -2226,6 +2241,7 @@ 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;