Compare commits

..

16 Commits

Author SHA1 Message Date
admin 30c4c4ed3c 0.13.11 : change register template an improve in user extension
Build release Docker image / Build Docker Images (push) Successful in 8m20s
2026-05-05 13:55:21 +02:00
admin ca2604d0fb Fix dokploy url
Build release Docker image / Build Docker Images (push) Successful in 5m21s
2026-05-04 21:46:09 +02:00
admin 574d81ac54 New upload way
Build release Docker image / Build Docker Images (push) Failing after 8m10s
2026-05-04 18:46:41 +02:00
admin 6034c4f07b Use zeptomail in prod
Build release Docker image / Build Docker Images (push) Failing after 10m38s
2026-05-04 09:43:59 +02:00
admin 945ad5d095 Fix strapi url
Build release Docker image / Build Docker Images (push) Failing after 13m22s
2026-05-04 01:05:48 +02:00
admin ce25ba2695 Change registry org
Build release Docker image / Build Docker Images (push) Failing after 7m58s
2026-05-03 22:41:10 +02:00
admin f7f0ee17d7 0.13.10 : add fiels on message and channel
Build release Docker image / Build Docker Images (push) Failing after 4m58s
2026-04-14 23:06:41 +02:00
admin 0648bf74bd 0.13.9 : change smartTags name 2026-04-13 21:56:07 +02:00
admin d469fc41e8 0.13.8 : invite by mail or contact and change mail provider on local 2026-04-07 22:15:59 +02:00
admin 5af0f28b39 Fix controller to remove upload 2026-04-06 16:42:54 +02:00
admin fd14d20dd0 fix 2026-04-06 00:52:32 +02:00
admin 7d020c33b6 Fix add logo 2026-04-06 00:52:13 +02:00
admin 1a3ea9e0c1 0.13.7 : add logo to choral 2026-04-06 00:10:27 +02:00
admin b0db6a5ddf 0.13.6 : change choral-membership 2026-03-15 19:37:44 +01:00
admin f13efb8707 0.13.5 : add roles 2026-03-15 10:48:07 +01:00
admin 75c90aa042 Change schema 2026-03-11 18:01:09 +01:00
23 changed files with 8632 additions and 247 deletions
+1
View File
@@ -5,3 +5,4 @@ dist
.cache .cache
.env .env
build build
.git
+2 -2
View File
@@ -5,9 +5,9 @@ on: [push]
env: env:
DOCKER_IMAGE_NAME: harmony-back DOCKER_IMAGE_NAME: harmony-back
DOCKER_REGISTRY_URL: git.harmonylab.ovh DOCKER_REGISTRY_URL: git.harmonylab.ovh
DOCKER_REGISTRY_ORG: harmony DOCKER_REGISTRY_ORG: admin
RELEASE_VERSION: 1.2.0 RELEASE_VERSION: 1.2.0
DOCKPLOY_WEBHOOK_URL: http://192.168.0.220:3000/api/deploy/HHWS7dR5rT-8vbXD6Zypv DOCKPLOY_WEBHOOK_URL: https://dokploy.harmonylab.ovh/api/deploy/fO2GDNPJAoNicWALwiBPa
DISCORD_WEBHOOK_URL: https://discord.com/api/webhooks/1433240422901088339/GAvL79ESBRabkB6rvxN2DsWI74KJ_Szgp9W2_PycPIY113rMCT_9LvIv-iTLCMD9W9qH DISCORD_WEBHOOK_URL: https://discord.com/api/webhooks/1433240422901088339/GAvL79ESBRabkB6rvxN2DsWI74KJ_Szgp9W2_PycPIY113rMCT_9LvIv-iTLCMD9W9qH
jobs: jobs:
+6
View File
@@ -5,6 +5,12 @@ RUN apk update && apk add --no-cache \
ARG NODE_ENV=production ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV} ENV NODE_ENV=${NODE_ENV}
# On les transforme en variables d'environnement pour le processus de build
ENV NODE_ENV=${NODE_ENV}
ARG URL=https://back.harmonylab.ovh
ENV URL=${URL}
# Strapi v4/v5 utilise aussi souvent celle-ci pour l'admin
ENV STRAPI_ADMIN_BACKEND_URL=${URL}
WORKDIR /opt/app WORKDIR /opt/app
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
+8 -7
View File
@@ -3,17 +3,18 @@ export default () => ({
config: { config: {
provider: "nodemailer", provider: "nodemailer",
providerOptions: { providerOptions: {
host: "mail.harmonychoral.com", host: "smtp.zeptomail.eu",
port: 465, port: 587,
auth: { auth: {
user: "admin@harmonychoral.com", user: "emailapikey",
pass: "Apslxnap12bn23", pass: "yA6KbHsJ4lrywWtTFUc+0pSC94lm/aE/2nzks3i2fpZ1LYXp3qE71RBvd4O4c2CLjdfT5a9UbIkVJoCwvIpbfpczPIBXJpTGTuv4P2uV48xh8ciEYNYjhJivALIWFqVOeBsnDyo4QfEjWA==",
}, },
// ... any custom nodemailer options debug: true,
logger: true,
}, },
settings: { settings: {
defaultFrom: "admin@harmonychoral.com", defaultFrom: "ChoralSync <noreply@choralsync.com>",
defaultReplyTo: "admin@harmonychoral.com", defaultReplyTo: "contact@choralsync.com",
}, },
}, },
}, },
+16 -12
View File
@@ -3,16 +3,18 @@ export default () => ({
config: { config: {
provider: "nodemailer", provider: "nodemailer",
providerOptions: { providerOptions: {
host: "mail.harmonychoral.com", host: "smtp.zeptomail.eu",
port: 465, port: 587,
auth: { auth: {
user: "admin@harmonychoral.com", user: "emailapikey",
pass: "Apslxnap12bn23", pass: "yA6KbHsJ4lrywWtTFUc+0pSC94lm/aE/2nzks3i2fpZ1LYXp3qE71RBvd4O4c2CLjdfT5a9UbIkVJoCwvIpbfpczPIBXJpTGTuv4P2uV48xh8ciEYNYjhJivALIWFqVOeBsnDyo4QfEjWA==",
}, },
debug: true,
logger: true,
}, },
settings: { settings: {
defaultFrom: "admin@harmonychoral.com", defaultFrom: "ChoralSync <noreply@choralsync.com>",
defaultReplyTo: "admin@harmonychoral.com", defaultReplyTo: "contact@choralsync.com",
}, },
}, },
}, },
@@ -21,16 +23,18 @@ export default () => ({
config: { config: {
provider: "aws-s3", provider: "aws-s3",
providerOptions: { providerOptions: {
baseUrl: "https://container.harmonylab.ovh/harmony", baseUrl: "https://container.choralsync.com",
credentials: { credentials: {
accessKeyId: "admin", accessKeyId: "a6f474b04f010543022788efc626cbfe",
secretAccessKey: "Apslxnap12bn23", secretAccessKey:
"eeeb631194378edee5145b3ef6314630b152fa186b3dab2fefdf8b48952cde21",
}, },
endpoint: "https://container.harmonylab.ovh", endpoint:
"https://4c59d3c67191bdd08e311a5a49c3ac98.r2.cloudflarestorage.com",
forcePathStyle: true, forcePathStyle: true,
region: "eu-west-3", region: "weur",
params: { params: {
Bucket: "harmony", Bucket: "choralsync",
}, },
}, },
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "harmony-back", "name": "harmony-back",
"version": "0.13.4", "version": "0.13.11",
"private": true, "private": true,
"description": "A Strapi application", "description": "A Strapi application",
"scripts": { "scripts": {
+4 -3
View File
@@ -58,14 +58,15 @@
"type": "text" "type": "text"
}, },
"medias": { "medias": {
"type": "media",
"multiple": true,
"required": false,
"allowedTypes": [ "allowedTypes": [
"images", "images",
"files", "files",
"videos", "videos",
"audios" "audios"
], ]
"type": "media",
"multiple": true
} }
} }
} }
@@ -4,7 +4,8 @@
"info": { "info": {
"singularName": "channel", "singularName": "channel",
"pluralName": "channels", "pluralName": "channels",
"displayName": "Channel" "displayName": "Channel",
"description": ""
}, },
"options": { "options": {
"draftAndPublish": false "draftAndPublish": false
@@ -33,6 +34,12 @@
"relation": "oneToMany", "relation": "oneToMany",
"target": "api::message.message", "target": "api::message.message",
"mappedBy": "channel" "mappedBy": "channel"
},
"description": {
"type": "text"
},
"permissions": {
"type": "json"
} }
} }
} }
@@ -36,6 +36,29 @@
"type": "component", "type": "component",
"repeatable": true, "repeatable": true,
"component": "user.permissions" "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 @@
/** import { factories } from "@strapi/strapi";
* choral-membership controller 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;
},
}),
);
@@ -103,6 +103,28 @@
}, },
"available_tags": { "available_tags": {
"type": "json" "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
} }
} }
} }
+1 -1
View File
@@ -33,5 +33,5 @@ export default factories.createCoreController(
const result = await super.create(ctx); const result = await super.create(ctx);
return result; return result;
}, },
}) }),
); );
@@ -25,6 +25,16 @@
} }
}, },
"component": "mail.mail" "component": "mail.mail"
},
"onInviteUser": {
"type": "component",
"repeatable": true,
"pluginOptions": {
"i18n": {
"localized": true
}
},
"component": "mail.mail"
} }
} }
} }
+59 -5
View File
@@ -1,7 +1,61 @@
/** import { factories } from "@strapi/strapi";
* mails service
*/
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;
}
},
}),
);
@@ -33,6 +33,14 @@
"type": "relation", "type": "relation",
"relation": "oneToOne", "relation": "oneToOne",
"target": "api::choral.choral" "target": "api::choral.choral"
},
"reactions": {
"type": "json"
},
"parent_message": {
"type": "relation",
"relation": "oneToOne",
"target": "api::message.message"
} }
} }
} }
+125 -2
View File
@@ -4,11 +4,134 @@
import { factories } from "@strapi/strapi"; 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( export default factories.createCoreController(
"api::message.message", "api::message.message",
({ strapi }) => ({ ({ strapi }) => ({
async create(ctx) { 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;
},
}),
); );
@@ -0,0 +1,9 @@
export default {
routes: [
{
method: "POST",
path: "/messages/:id/react",
handler: "message.addReaction",
},
],
};
+222
View File
@@ -0,0 +1,222 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Confirmation d'e-mail - ChoralSync</title>
<!--[if mso]>
<style type="text/css">
body,
table,
td,
a {
font-family: Arial, Helvetica, sans-serif !important;
}
</style>
<![endif]-->
</head>
<body
style="
margin: 0;
padding: 0;
background-color: #f9fafb;
font-family:
&quot;Inter&quot;,
-apple-system,
BlinkMacSystemFont,
&quot;Segoe UI&quot;,
Roboto,
Helvetica,
Arial,
sans-serif;
"
>
<table
width="100%"
border="0"
cellspacing="0"
cellpadding="0"
style="background-color: #f9fafb; padding: 40px 20px"
>
<tr>
<td align="center">
<!-- Main Container -->
<table
width="100%"
border="0"
cellspacing="0"
cellpadding="0"
style="
max-width: 650px;
background-color: #ffffff;
border-radius: 16px;
border: 1px solid #e2e8f0;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
"
>
<!-- Header -->
<tr>
<td style="padding: 32px 32px 16px 32px">
<div
style="
font-size: 24px;
font-weight: 700;
color: #111827;
letter-spacing: -0.025em;
"
>
ChoralSync
</div>
<div
style="
font-size: 14px;
color: #6b7280;
font-weight: 500;
margin-top: 4px;
"
>
Bienvenue — plus quune étape
</div>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 0 32px 32px 32px">
<h1
style="
font-size: 24px;
font-weight: 700;
color: #111827;
margin-top: 32px;
margin-bottom: 16px;
"
>
Bonjour {{USER_NAME}},
</h1>
<p
style="
font-size: 18px;
line-height: 1.6;
color: #4b5563;
margin-bottom: 32px;
"
>
Merci de t'être inscrit. Clique sur le bouton ci-dessous pour
confirmer ton adresse e-mail et activer ton compte.
</p>
<!-- CTA Button -->
<table
border="0"
cellspacing="0"
cellpadding="0"
style="margin-bottom: 40px"
>
<tr>
<td
align="center"
bgcolor="#0b1c30"
style="border-radius: 12px"
>
<a
href="{{CONFIRM_URL}}"
target="_blank"
style="
display: inline-block;
padding: 16px 32px;
font-size: 18px;
font-weight: 600;
color: #ffffff;
text-decoration: none;
border-radius: 12px;
"
>
Confirmer mon e-mail →
</a>
</td>
</tr>
</table>
<!-- Fallback Link -->
<div style="margin-top: 32px">
<p
style="font-size: 14px; color: #6b7280; margin-bottom: 12px"
>
Si le bouton ne fonctionne pas, copie-colle ce lien dans ton
navigateur :
</p>
<div
style="
background-color: #f1f5f9;
border-radius: 12px;
padding: 16px;
border: 1px solid #e2e8f0;
"
>
<a
href="{{CONFIRM_URL}}"
style="
color: #0ea5e9;
font-size: 14px;
text-decoration: underline;
word-break: break-all;
font-weight: 500;
"
>
{{CONFIRM_URL}}
</a>
</div>
</div>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 24px 32px; border-top: 1px solid #f1f5f9">
<p style="font-size: 14px; color: #9ca3af; margin: 0 0 8px 0">
Si tu n'es pas à l'origine de cette inscription, ignore cet
e-mail.
</p>
<p style="font-size: 14px; color: #9ca3af; margin: 0">
© ChoralSync 2026 •
<a
href="https://www.choralsync.com"
style="color: #9ca3af; text-decoration: underline"
>www.choralsync.com</a
>
</p>
</td>
</tr>
</table>
<!-- External Footer -->
<table
width="100%"
border="0"
cellspacing="0"
cellpadding="0"
style="max-width: 650px; margin-top: 32px"
>
<tr>
<td align="center" style="font-size: 14px; color: #9ca3af">
Besoin d'aide ? Réponds directement à cet e-mail ou consulte
notre
<a
href="#"
style="
color: #0ea5e9;
font-weight: 500;
text-decoration: underline;
"
>centre d'aide</a
>.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
module.exports = (plugin) => {
// 1. LE SECRET : On ajoute le champ 'path' au schéma officiel de Strapi.
// Strapi va ainsi créer la colonne en BDD et arrêter de supprimer notre variable.
plugin.contentTypes.file.schema.attributes.path = {
type: "string",
configurable: false,
};
const originalServiceFactory = plugin.services.upload;
plugin.services.upload = ({ strapi }) => {
const service = originalServiceFactory({ strapi });
return {
...service,
// 2. Surcharge de l'Uploader
async upload(fileData, usage) {
const ctx = strapi.requestContext.get();
// On récupère le custom path envoyé par ton client Next.js
const customPath = ctx?.request?.body?.path;
if (customPath) {
// Pour que le provider S3 l'utilise tout de suite (R2)
fileData.path = customPath;
// Pour que Strapi l'enregistre dans notre nouvelle colonne BDD
if (!fileData.fileInfo) fileData.fileInfo = {};
fileData.fileInfo.path = customPath;
}
// Le service original va maintenant sauvegarder le path en BDD !
return service.upload(fileData, usage);
},
// Note : Plus besoin de surcharger `remove` (l'équivalent de delete) !
// Comme la colonne 'path' est maintenant en BDD, le service original
// récupère l'info et la passe au provider S3 qui supprime le bon dossier.
};
};
return plugin;
};
@@ -265,7 +265,7 @@
"target": "api::order.order", "target": "api::order.order",
"mappedBy": "user" "mappedBy": "user"
}, },
"admin_tags": { "smartTags": {
"type": "json" "type": "json"
} }
} }
+93 -169
View File
@@ -1,7 +1,7 @@
"use strict"; "use strict";
const lod = require("lodash"); const lod = require("lodash");
const utils = require("@strapi/utils"); const utils = require("@strapi/utils");
const { concat, compact, isArray, toNumber, getOr } = require("lodash/fp"); const { toNumber, getOr } = require("lodash/fp");
const cryptoLib = require("crypto"); const cryptoLib = require("crypto");
const bcrypt = require("bcryptjs"); const bcrypt = require("bcryptjs");
const fs = require("fs").promises; const fs = require("fs").promises;
@@ -9,16 +9,60 @@ const path = require("path");
module.exports = (plugin) => { module.exports = (plugin) => {
const rawProviders = plugin.services.providers({ strapi }); const rawProviders = plugin.services.providers({ strapi });
const { ApplicationError, ValidationError, ForbiddenError } = utils.errors; const { ApplicationError, ValidationError } = utils.errors;
const USER_MODEL_UID = "plugin::users-permissions.user"; const USER_MODEL_UID = "plugin::users-permissions.user";
const sanitizeUser = (user, ctx) => { const sanitizeUser = async (user, ctx) => {
const { auth } = ctx.state; const { auth } = ctx.state;
const userSchema = strapi.getModel("plugin::users-permissions.user"); const userSchema = strapi.getModel("plugin::users-permissions.user");
return strapi.contentAPI.sanitize.output(user, userSchema, { auth }); return strapi.contentAPI.sanitize.output(user, userSchema, { auth });
}; };
const getUserStats = async (userId) => {
const [
contactsCount,
groupsCount,
postsCount,
eventsCount,
followersCount,
followingCount,
] = await Promise.all([
strapi.db.query("api::contact.contact").count({
where: {
$or: [{ owner: userId }, { user: userId }],
},
}),
strapi.db.query("api::group-membership.group-membership").count({
where: {
user: userId,
role: { $in: ["owner", "member", "admin"] },
},
}),
strapi.db.query("api::post-ownership.post-ownership").count({
where: { author: userId },
}),
strapi.db.query("api::event-relationship.event-relationship").count({
where: { author: userId },
}),
strapi.db.query("api::contact.contact").count({
where: { user: userId, state: "follow" },
}),
strapi.db.query("api::contact.contact").count({
where: { owner: userId, state: "follow" },
}),
]);
return {
contacts: contactsCount,
groups: groupsCount,
posts: postsCount,
events: eventsCount,
followers: followersCount,
following: followingCount,
};
};
const ensureHashedPasswords = async (values) => { const ensureHashedPasswords = async (values) => {
const attributes = strapi.getModel(USER_MODEL_UID).attributes; const attributes = strapi.getModel(USER_MODEL_UID).attributes;
@@ -26,7 +70,7 @@ module.exports = (plugin) => {
if (attributes[key] && attributes[key].type === "password") { if (attributes[key] && attributes[key].type === "password") {
// Check if a custom encryption.rounds has been set on the password attribute // Check if a custom encryption.rounds has been set on the password attribute
const rounds = toNumber( const rounds = toNumber(
getOr(10, "encryption.rounds", attributes[key]) getOr(10, "encryption.rounds", attributes[key]),
); );
values[key] = await bcrypt.hash(values[key], rounds); values[key] = await bcrypt.hash(values[key], rounds);
} }
@@ -78,7 +122,7 @@ module.exports = (plugin) => {
to: user.email, to: user.email,
subject: onCreateUser?.subject ?? "Confirme ton adresse e-mail", subject: onCreateUser?.subject ?? "Confirme ton adresse e-mail",
html, html,
from: "ChoralSync <admin@harmonychoral.com>", from: "ChoralSync <contact@choralsync.com>",
}); });
return { ok: true }; return { ok: true };
@@ -111,7 +155,7 @@ module.exports = (plugin) => {
query.access_token || query.code || query.oauth_token; query.access_token || query.code || query.oauth_token;
if (!accessToken) { if (!accessToken) {
throw new Error("No access_token."); throw new ApplicationError("No access_token.");
} }
const profile = await getProfile(provider, query); const profile = await getProfile(provider, query);
@@ -119,7 +163,7 @@ module.exports = (plugin) => {
const email = lod.toLower(profile.email); const email = lod.toLower(profile.email);
if (!email) { if (!email) {
throw new Error("Email was not available."); throw new ApplicationError("Email was not available.");
} }
const users = await strapi.db const users = await strapi.db
@@ -142,7 +186,7 @@ module.exports = (plugin) => {
} }
if (lod.isEmpty(user) && !advancedSettings.allow_register) { if (lod.isEmpty(user) && !advancedSettings.allow_register) {
throw new Error("Register action is actually not available."); throw new ApplicationError("Register action is actually not available.");
} }
if (!lod.isEmpty(user)) { if (!lod.isEmpty(user)) {
@@ -150,7 +194,7 @@ module.exports = (plugin) => {
} }
if (users.length && advancedSettings.unique_email) { if (users.length && advancedSettings.unique_email) {
throw new Error("Email is already taken."); throw new ApplicationError("Email is already taken.");
} }
const defaultRole = await strapi.db const defaultRole = await strapi.db
@@ -174,8 +218,6 @@ module.exports = (plugin) => {
}; };
}; };
const originalMe = plugin.controllers.user.me;
plugin.controllers.user.me = async (ctx) => { plugin.controllers.user.me = async (ctx) => {
const fullUser = await strapi.db const fullUser = await strapi.db
.query("plugin::users-permissions.user") .query("plugin::users-permissions.user")
@@ -186,14 +228,14 @@ module.exports = (plugin) => {
if (fullUser.contacts && Array.isArray(fullUser.contacts)) { if (fullUser.contacts && Array.isArray(fullUser.contacts)) {
fullUser.contacts = fullUser.contacts.filter( fullUser.contacts = fullUser.contacts.filter(
(contact) => contact.state === "accepted" (contact) => contact.state === "accepted",
); );
} }
if (fullUser.post_ownerships && Array.isArray(fullUser.post_ownerships)) { if (fullUser.post_ownerships && Array.isArray(fullUser.post_ownerships)) {
fullUser.post_ownerships = fullUser.post_ownerships.filter( fullUser.post_ownerships = fullUser.post_ownerships.filter(
(ownership) => (ownership) =>
ownership.contextType === "user" && ownership.relation === "owner" ownership.contextType === "user" && ownership.relation === "owner",
); );
} }
@@ -202,62 +244,15 @@ module.exports = (plugin) => {
Array.isArray(fullUser.group_memberships) Array.isArray(fullUser.group_memberships)
) { ) {
fullUser.group_memberships = fullUser.group_memberships.filter( fullUser.group_memberships = fullUser.group_memberships.filter(
(membership) => ["member", "admin", "owner"].includes(membership.role) (membership) => ["member", "admin", "owner"].includes(membership.role),
); );
} }
const user = ctx.state.user; const stats = await getUserStats(ctx.state.user.id);
const userId = user.id;
const [
contactsCount,
groupsCount,
postsCount,
eventsCount,
followersCount,
followingCount,
] = await Promise.all([
strapi.db.query("api::contact.contact").count({
where: {
$or: [{ owner: userId }, { user: userId }],
},
}),
strapi.db.query("api::group-membership.group-membership").count({
where: {
user: userId,
role: { $in: ["owner", "member", "admin"] },
},
}),
strapi.db
.query("api::post-ownership.post-ownership")
.count({ where: { author: userId } }),
strapi.db
.query("api::event-relationship.event-relationship")
.count({ where: { author: userId } }),
strapi.db.query("api::contact.contact").count({
where: {
user: userId,
state: "follow",
},
}),
strapi.db.query("api::contact.contact").count({
where: {
owner: userId,
state: "follow",
},
}),
]);
const result = { const result = {
...JSON.parse(JSON.stringify(fullUser)), ...JSON.parse(JSON.stringify(fullUser)),
stats: { stats,
contacts: contactsCount,
groups: groupsCount,
posts: postsCount,
events: eventsCount,
followers: followersCount,
following: followingCount,
},
}; };
return result; return result;
@@ -279,10 +274,6 @@ module.exports = (plugin) => {
// 4️⃣ Ajoute un champ calculé // 4️⃣ Ajoute un champ calculé
user.profileCompleted = Boolean(user.username && user.surname); user.profileCompleted = Boolean(user.username && user.surname);
// 3️⃣ Supprime les champs sensibles
const sensitive = ["password", "resetPasswordToken", "confirmationToken"];
sensitive.forEach((key) => delete user[key]); //post_ownerships
// 5️⃣ Refetch avec relations peuplées // 5️⃣ Refetch avec relations peuplées
try { try {
const populatedUser = await strapi.entityService.findOne( const populatedUser = await strapi.entityService.findOne(
@@ -291,6 +282,7 @@ module.exports = (plugin) => {
{ {
populate: { populate: {
post_ownerships: { post_ownerships: {
filters: { contextType: "user", relation: "owner" },
populate: { populate: {
post: { post: {
populate: { populate: {
@@ -300,6 +292,7 @@ module.exports = (plugin) => {
}, },
}, },
contacts: { contacts: {
filters: { state: "accepted" },
populate: { populate: {
owner: { owner: {
populate: { populate: {
@@ -314,6 +307,7 @@ module.exports = (plugin) => {
}, },
}, },
group_memberships: { group_memberships: {
filters: { role: { $in: ["member", "admin", "owner"] } },
populate: { populate: {
group: { group: {
populate: { populate: {
@@ -324,29 +318,8 @@ module.exports = (plugin) => {
}, },
activities: true, activities: true,
}, },
} },
); );
// Fusionne les données originales (permissions/serialization) avec les relations
user = { ...user, ...populatedUser };
if (user.contacts && Array.isArray(user.contacts)) {
user.contacts = user.contacts.filter(
(contact) => contact.state === "accepted"
);
}
if (user.post_ownerships && Array.isArray(user.post_ownerships)) {
user.post_ownerships = user.post_ownerships.filter(
(ownership) =>
ownership.contextType === "user" && ownership.relation === "owner"
);
}
if (user.group_memberships && Array.isArray(user.group_memberships)) {
user.group_memberships = user.group_memberships.filter((membership) =>
["member", "admin", "owner"].includes(membership.role)
);
}
const eventRelationships = await strapi.db const eventRelationships = await strapi.db
.query("api::event-relationship.event-relationship") .query("api::event-relationship.event-relationship")
@@ -356,84 +329,28 @@ module.exports = (plugin) => {
event: true, event: true,
}, },
}); });
user.event_relationships = eventRelationships || [];
const [ const stats = await getUserStats(user.id);
contactsCount,
groupsCount, const sanitizedPopulatedUser = await sanitizeUser(populatedUser, ctx) as object;
postsCount,
eventsCount, // Fusionne les données originales avec les relations sécurisées
followersCount, user = {
followingCount, ...user,
] = await Promise.all([ ...sanitizedPopulatedUser,
strapi.db.query("api::contact.contact").count({ event_relationships: eventRelationships || [],
where: { stats
$or: [{ owner: user.id }, { user: user.id }],
},
}),
strapi.db.query("api::group-membership.group-membership").count({
where: {
user: user.id,
role: { $in: ["owner", "member", "admin"] },
},
}),
strapi.db
.query("api::post-ownership.post-ownership")
.count({ where: { author: user.id } }),
strapi.db
.query("api::event-relationship.event-relationship")
.count({ where: { author: user.id } }),
strapi.db.query("api::contact.contact").count({
where: {
user: user.id,
state: "follow",
},
}),
strapi.db.query("api::contact.contact").count({
where: {
owner: user.id,
state: "follow",
},
}),
]);
user.stats = {
contacts: contactsCount,
groups: groupsCount,
posts: postsCount,
events: eventsCount,
followers: followersCount,
following: followingCount,
}; };
} catch (err) { } catch (err) {
console.error("Erreur populate relations user:", err); console.error("Erreur populate relations user:", err);
// fallback : retourne juste l'utilisateur original // fallback : retourne juste l'utilisateur original
user.stats = await getUserStats(user.id);
} }
ctx.body = user; ctx.body = user;
return ctx; return ctx;
}; };
const uploadImage = async (ctx, label: string, username: string) => { // Removed unused uploadImage function
const key = `${label}Image`;
if (ctx.request.files[key]) {
const files = Array.isArray(ctx.request.files[key])
? ctx.request.files[key][0]
: ctx.request.files[key];
const extension = files.originalFilename.match(/\.[0-9a-z]+$/i);
const payload = {
fileInfo: {
caption: "undefined",
alternativeText: username || "",
name: `${username}_avatar${extension}`,
},
};
const asset = await strapi.services["plugin::upload.upload"].upload({
data: payload,
files,
});
return asset[0].id;
}
return 0;
};
plugin.services.providers = providers; plugin.services.providers = providers;
@@ -444,7 +361,14 @@ module.exports = (plugin) => {
return ctx.unauthorized(); return ctx.unauthorized();
} }
const data = JSON.parse(ctx.request.body.data); let data = ctx.request.body.data || ctx.request.body;
if (typeof data === "string") {
try {
data = JSON.parse(data);
} catch (err) {
throw new ValidationError("Invalid JSON format in request body data");
}
}
const newData = lod.pick(data, [ const newData = lod.pick(data, [
"email", "email",
@@ -463,6 +387,7 @@ module.exports = (plugin) => {
"languages", "languages",
"parameter", "parameter",
"privacy", "privacy",
"avatar",
]); ]);
/* /*
@@ -506,7 +431,7 @@ module.exports = (plugin) => {
ctx.params = { id: user.id }; ctx.params = { id: user.id };
const keysExcludingParameterAndPrivacy = Object.keys(newData).filter( const keysExcludingParameterAndPrivacy = Object.keys(newData).filter(
(key) => key !== "parameter" && key !== "privacy" (key) => key !== "parameter" && key !== "privacy",
); );
if (keysExcludingParameterAndPrivacy.length === 0) { if (keysExcludingParameterAndPrivacy.length === 0) {
@@ -520,9 +445,6 @@ module.exports = (plugin) => {
return ctx; return ctx;
}); });
} else { } else {
const avatarId = await uploadImage(ctx, "avatar", newData.username);
if (avatarId != 0) ctx.request.body.avatar = avatarId;
return plugin.controllers.user.update(ctx); return plugin.controllers.user.update(ctx);
} }
}; };
@@ -533,9 +455,7 @@ module.exports = (plugin) => {
await sendConfirmationEmail(result); await sendConfirmationEmail(result);
await strapi await strapi.service("api::notification.notification")?.addActivity({
.service("api::notification.notification")
?.addActivity({
userId: result.id, userId: result.id,
activityMessage: `Bienvenue ${result.username}, Ton compte est maintenant activé.`, activityMessage: `Bienvenue ${result.username}, Ton compte est maintenant activé.`,
activityUser: result.username, activityUser: result.username,
@@ -548,14 +468,18 @@ module.exports = (plugin) => {
?.createNotification({ ?.createNotification({
title: "Bienvenue !", title: "Bienvenue !",
message: `Ton compte est maintenant activé.`, message: `Ton compte est maintenant activé.`,
type: "success", type: "accountCreation",
target_user: result.id, target_user: result.id,
source: "system", source: "system",
payload: { email: result.email }, payload: {
email: result.email,
url: "/app/user",
description: "Penses à remplir ton profil",
},
}); });
strapi.log.info( strapi.log.info(
`🔔 Notification envoyée pour l'utilisateur ${result.id}` `🔔 Notification envoyée pour l'utilisateur ${result.id}`,
); );
}, },
}; };
+28 -1
View File
@@ -560,6 +560,7 @@ export interface ApiBoardBoard extends Struct.CollectionTypeSchema {
export interface ApiChannelChannel extends Struct.CollectionTypeSchema { export interface ApiChannelChannel extends Struct.CollectionTypeSchema {
collectionName: 'channels'; collectionName: 'channels';
info: { info: {
description: '';
displayName: 'Channel'; displayName: 'Channel';
pluralName: 'channels'; pluralName: 'channels';
singularName: 'channel'; singularName: 'channel';
@@ -572,6 +573,7 @@ export interface ApiChannelChannel extends Struct.CollectionTypeSchema {
createdAt: Schema.Attribute.DateTime; createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private; Schema.Attribute.Private;
description: Schema.Attribute.Text;
locale: Schema.Attribute.String & Schema.Attribute.Private; locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation< localizations: Schema.Attribute.Relation<
'oneToMany', 'oneToMany',
@@ -580,6 +582,7 @@ export interface ApiChannelChannel extends Struct.CollectionTypeSchema {
Schema.Attribute.Private; Schema.Attribute.Private;
messages: Schema.Attribute.Relation<'oneToMany', 'api::message.message'>; messages: Schema.Attribute.Relation<'oneToMany', 'api::message.message'>;
name: Schema.Attribute.String; name: Schema.Attribute.String;
permissions: Schema.Attribute.JSON;
publishedAt: Schema.Attribute.DateTime; publishedAt: Schema.Attribute.DateTime;
type: Schema.Attribute.Enumeration<['TEXT', 'AUDIO', 'VIDEO']>; type: Schema.Attribute.Enumeration<['TEXT', 'AUDIO', 'VIDEO']>;
updatedAt: Schema.Attribute.DateTime; updatedAt: Schema.Attribute.DateTime;
@@ -723,15 +726,22 @@ export interface ApiChoralMembershipChoralMembership
createdAt: Schema.Attribute.DateTime; createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private; 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; locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation< localizations: Schema.Attribute.Relation<
'oneToMany', 'oneToMany',
'api::choral-membership.choral-membership' 'api::choral-membership.choral-membership'
> & > &
Schema.Attribute.Private; Schema.Attribute.Private;
permission_exceptions: Schema.Attribute.JSON;
permissions: Schema.Attribute.Component<'user.permissions', true>; permissions: Schema.Attribute.Component<'user.permissions', true>;
publishedAt: Schema.Attribute.DateTime; publishedAt: Schema.Attribute.DateTime;
role: Schema.Attribute.Enumeration<['member', 'admin', 'owner']>; 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; updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private; Schema.Attribute.Private;
@@ -799,6 +809,7 @@ export interface ApiChoralChoral extends Struct.CollectionTypeSchema {
'oneToMany', 'oneToMany',
'api::announcement.announcement' 'api::announcement.announcement'
>; >;
available_roles: Schema.Attribute.JSON;
available_tags: Schema.Attribute.JSON; available_tags: Schema.Attribute.JSON;
boards: Schema.Attribute.Relation<'oneToMany', 'api::board.board'>; boards: Schema.Attribute.Relation<'oneToMany', 'api::board.board'>;
calendar: Schema.Attribute.Relation<'oneToMany', 'api::event.event'>; calendar: Schema.Attribute.Relation<'oneToMany', 'api::event.event'>;
@@ -809,13 +820,16 @@ export interface ApiChoralChoral extends Struct.CollectionTypeSchema {
createdAt: Schema.Attribute.DateTime; createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private; Schema.Attribute.Private;
description: Schema.Attribute.Text;
email: Schema.Attribute.Email; email: Schema.Attribute.Email;
founded: Schema.Attribute.Integer;
locale: Schema.Attribute.String & Schema.Attribute.Private; locale: Schema.Attribute.String & Schema.Attribute.Private;
localizations: Schema.Attribute.Relation< localizations: Schema.Attribute.Relation<
'oneToMany', 'oneToMany',
'api::choral.choral' 'api::choral.choral'
> & > &
Schema.Attribute.Private; Schema.Attribute.Private;
logo: Schema.Attribute.Media<'images' | 'files' | 'videos' | 'audios'>;
memberships: Schema.Attribute.Relation< memberships: Schema.Attribute.Relation<
'oneToMany', 'oneToMany',
'api::choral-membership.choral-membership' 'api::choral-membership.choral-membership'
@@ -832,6 +846,7 @@ export interface ApiChoralChoral extends Struct.CollectionTypeSchema {
phoneNumber: Schema.Attribute.String; phoneNumber: Schema.Attribute.String;
postal: Schema.Attribute.Integer; postal: Schema.Attribute.Integer;
publishedAt: Schema.Attribute.DateTime; publishedAt: Schema.Attribute.DateTime;
social_networks: Schema.Attribute.JSON;
updatedAt: Schema.Attribute.DateTime; updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private; Schema.Attribute.Private;
@@ -1359,6 +1374,12 @@ export interface ApiMailsMails extends Struct.SingleTypeSchema {
localized: true; localized: true;
}; };
}>; }>;
onInviteUser: Schema.Attribute.Component<'mail.mail', true> &
Schema.Attribute.SetPluginOptions<{
i18n: {
localized: true;
};
}>;
publishedAt: Schema.Attribute.DateTime; publishedAt: Schema.Attribute.DateTime;
updatedAt: Schema.Attribute.DateTime; updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
@@ -1395,7 +1416,12 @@ export interface ApiMessageMessage extends Struct.CollectionTypeSchema {
'api::message.message' 'api::message.message'
> & > &
Schema.Attribute.Private; Schema.Attribute.Private;
parent_message: Schema.Attribute.Relation<
'oneToOne',
'api::message.message'
>;
publishedAt: Schema.Attribute.DateTime; publishedAt: Schema.Attribute.DateTime;
reactions: Schema.Attribute.JSON;
updatedAt: Schema.Attribute.DateTime; updatedAt: Schema.Attribute.DateTime;
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private; Schema.Attribute.Private;
@@ -1945,6 +1971,7 @@ export interface PluginUploadFile extends Struct.CollectionTypeSchema {
Schema.Attribute.Private; Schema.Attribute.Private;
mime: Schema.Attribute.String & Schema.Attribute.Required; mime: Schema.Attribute.String & Schema.Attribute.Required;
name: Schema.Attribute.String & Schema.Attribute.Required; name: Schema.Attribute.String & Schema.Attribute.Required;
path: Schema.Attribute.String;
previewUrl: Schema.Attribute.String; previewUrl: Schema.Attribute.String;
provider: Schema.Attribute.String & Schema.Attribute.Required; provider: Schema.Attribute.String & Schema.Attribute.Required;
provider_metadata: Schema.Attribute.JSON; provider_metadata: Schema.Attribute.JSON;
@@ -2122,7 +2149,6 @@ export interface PluginUsersPermissionsUser
attributes: { attributes: {
activities: Schema.Attribute.Component<'group.activity', true>; activities: Schema.Attribute.Component<'group.activity', true>;
address: Schema.Attribute.Text; address: Schema.Attribute.Text;
admin_tags: Schema.Attribute.JSON;
announcements: Schema.Attribute.Relation< announcements: Schema.Attribute.Relation<
'oneToMany', 'oneToMany',
'api::announcement.announcement' 'api::announcement.announcement'
@@ -2216,6 +2242,7 @@ export interface PluginUsersPermissionsUser
'plugin::users-permissions.role' 'plugin::users-permissions.role'
>; >;
saved_posts: Schema.Attribute.Relation<'oneToMany', 'api::post.post'>; saved_posts: Schema.Attribute.Relation<'oneToMany', 'api::post.post'>;
smartTags: Schema.Attribute.JSON;
subscriptionPlan: Schema.Attribute.Enumeration<['free', 'pro', 'premium']> & subscriptionPlan: Schema.Attribute.Enumeration<['free', 'pro', 'premium']> &
Schema.Attribute.DefaultTo<'free'>; Schema.Attribute.DefaultTo<'free'>;
surname: Schema.Attribute.String; surname: Schema.Attribute.String;