Merge pull request 'Commentaires fédérés via Mastodon (bokante.o-k-i.net) — backend' (#5) from feat/comment-fedi into master
Déploiement API BETA / build (push) Successful in 2m20s
Déploiement API PROD / build (push) Successful in 2m24s
Déploiement API BETA / deploy (push) Successful in 44s
Déploiement API PROD / deploy (push) Successful in 53s

Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
2026-07-04 21:24:31 +00:00
12 changed files with 631 additions and 0 deletions
+36
View File
@@ -1,5 +1,7 @@
const path = require('path'); const path = require('path');
const {backupDatabase} = require('../src/utils/backup-database'); const {backupDatabase} = require('../src/utils/backup-database');
const {importBokanteComments} = require('../src/utils/import-bokante-comments');
const {backfillBokanteMirror} = require('../src/utils/backfill-bokante-mirrors');
module.exports = { module.exports = {
myJob: { myJob: {
@@ -19,4 +21,38 @@ module.exports = {
tz: 'Indian/Reunion', tz: 'Indian/Reunion',
}, },
}, },
importBokanteComments: {
task: async ({ strapi }) => {
if (!process.env.BOKANTE_ACCESS_TOKEN) {
return;
}
const {imported} = await importBokanteComments({strapi});
if (imported > 0) {
strapi.log.info(`Import bokante : ${imported} commentaire(s) importé(s).`);
}
},
options: {
rule: '*/20 * * * *',
tz: 'Indian/Reunion',
},
},
backfillBokanteMirror: {
task: async ({ strapi }) => {
if (!process.env.BOKANTE_ACCESS_TOKEN) {
return;
}
const {backfilled, slug} = await backfillBokanteMirror({strapi});
if (backfilled) {
strapi.log.info(`Backfill bokante : parole "${slug}" publiée.`);
}
},
options: {
rule: process.env.BOKANTE_BACKFILL_CRON || '0 * * * *',
tz: 'Indian/Reunion',
},
},
}; };
@@ -29,6 +29,31 @@
"type": "relation", "type": "relation",
"relation": "oneToOne", "relation": "oneToOne",
"target": "api::parole.parole" "target": "api::parole.parole"
},
"origine": {
"type": "enumeration",
"enum": ["local", "activitypub"],
"default": "local",
"required": true
},
"auteurNom": {
"type": "string"
},
"auteurHandle": {
"type": "string"
},
"auteurAvatarUrl": {
"type": "string"
},
"auteurProfilUrl": {
"type": "string"
},
"remoteId": {
"type": "string",
"unique": true
},
"remoteUrl": {
"type": "string"
} }
} }
} }
@@ -118,6 +118,113 @@ describe('beforeUpdate — notifications Telegram/Revolt', () => {
}); });
}); });
describe('afterCreate — publication miroir bokante', () => {
const originalEnv = {...process.env};
const bokanteMastodon = require('../../../../../utils/bokante-mastodon');
const originalCreateStatus = bokanteMastodon.createStatus;
function buildStrapi() {
const dbQuery = {
findOne: vi.fn(async () => null),
updateMany: vi.fn()
};
return {
db: {query: vi.fn(() => dbQuery)},
plugins: {email: {services: {email: {send: vi.fn()}}}},
log: {error: vi.fn()}
};
}
function buildEvent(resultOverrides = {}) {
return {
params: {data: {titre: 'Mon titre'}},
result: {
documentId: 'doc-1',
titre: 'Mon titre',
slug: 'mon-titre',
publishedAt: '2026-07-04T00:00:00.000Z',
bokanteStatusId: null,
...resultOverrides
}
};
}
afterEach(() => {
process.env = {...originalEnv};
bokanteMastodon.createStatus = originalCreateStatus;
delete global.strapi;
});
it('publie un statut miroir et synchronise bokanteStatusId sur le documentId', async () => {
process.env.BOKANTE_ACCESS_TOKEN = 'fake-token';
bokanteMastodon.createStatus = vi.fn(async () => ({id: '112233'}));
const strapiMock = buildStrapi();
const {afterCreate} = await loadLifecycles(strapiMock);
await afterCreate(buildEvent());
expect(bokanteMastodon.createStatus).toHaveBeenCalledTimes(1);
expect(bokanteMastodon.createStatus.mock.calls[0][0]).toContain('Mon titre');
expect(strapiMock.db.query('api::parole.parole').updateMany).toHaveBeenCalledWith({
where: {documentId: 'doc-1'},
data: {bokanteStatusId: '112233'}
});
});
it('ne publie rien si la ligne créée n\'est pas publiée (simple brouillon)', async () => {
process.env.BOKANTE_ACCESS_TOKEN = 'fake-token';
bokanteMastodon.createStatus = vi.fn(async () => ({id: '999'}));
const strapiMock = buildStrapi();
const {afterCreate} = await loadLifecycles(strapiMock);
await afterCreate(buildEvent({publishedAt: null}));
expect(bokanteMastodon.createStatus).not.toHaveBeenCalled();
});
it('ne republie pas si bokanteStatusId existe déjà', async () => {
process.env.BOKANTE_ACCESS_TOKEN = 'fake-token';
bokanteMastodon.createStatus = vi.fn(async () => ({id: '999'}));
const strapiMock = buildStrapi();
const {afterCreate} = await loadLifecycles(strapiMock);
await afterCreate(buildEvent({bokanteStatusId: '112233'}));
expect(bokanteMastodon.createStatus).not.toHaveBeenCalled();
});
it('ne publie rien si BOKANTE_ACCESS_TOKEN n\'est pas configuré', async () => {
delete process.env.BOKANTE_ACCESS_TOKEN;
bokanteMastodon.createStatus = vi.fn(async () => ({id: '999'}));
const strapiMock = buildStrapi();
const {afterCreate} = await loadLifecycles(strapiMock);
await afterCreate(buildEvent());
expect(bokanteMastodon.createStatus).not.toHaveBeenCalled();
});
it('n\'interrompt pas la création quand bokante échoue', async () => {
process.env.BOKANTE_ACCESS_TOKEN = 'fake-token';
bokanteMastodon.createStatus = vi.fn(async () => {
throw new Error('boom');
});
const strapiMock = buildStrapi();
const {afterCreate} = await loadLifecycles(strapiMock);
await expect(afterCreate(buildEvent())).resolves.not.toThrow();
expect(strapiMock.log.error).toHaveBeenCalledWith(expect.stringContaining('bokante'));
expect(strapiMock.db.query('api::parole.parole').updateMany).not.toHaveBeenCalled();
});
});
describe('beforeUpdate — createdBy/updatedBy', () => { describe('beforeUpdate — createdBy/updatedBy', () => {
afterEach(() => { afterEach(() => {
delete global.strapi; delete global.strapi;
@@ -2,6 +2,7 @@
const slugify = require('slugify'); const slugify = require('slugify');
const axios = require('axios'); const axios = require('axios');
const bokanteMastodon = require('../../../../utils/bokante-mastodon');
const utils = require('@strapi/utils'); const utils = require('@strapi/utils');
const { ApplicationError } = utils.errors; const { ApplicationError } = utils.errors;
@@ -272,6 +273,27 @@ module.exports = {
}, },
afterCreate: async event => { afterCreate: async event => {
const {data} = event.params; const {data} = event.params;
// Avec draftAndPublish, "publier" crée une nouvelle ligne (la version publiée)
// au lieu de mettre à jour la ligne existante : c'est ici, et non dans
// beforeUpdate, qu'un événement de publication est détectable.
if (event.result?.publishedAt && !event.result?.bokanteStatusId && process.env.BOKANTE_ACCESS_TOKEN) {
try {
const status = await bokanteMastodon.createStatus(
`"${event.result.titre}" — nouvelle parole sur pawol.nu\n${process.env.WEBSITE_URL}/paroles/${event.result.slug}`
);
// Synchronise brouillon et version publiée pour éviter une republication
// en double au prochain cycle dépublier/republier (qui recrée la ligne
// publiée à partir du brouillon).
await strapi.db.query('api::parole.parole').updateMany({
where: {documentId: event.result.documentId},
data: {bokanteStatusId: status.id}
});
} catch (err) {
strapi.log.error(`Publication bokante : ${err.message}`);
}
}
const user = await jwennUserEpiId(data?.user?.id); const user = await jwennUserEpiId(data?.user?.id);
const userAdmin = await jwennUserAdminEpiId(data?.createdBy); const userAdmin = await jwennUserAdminEpiId(data?.createdBy);
const superAdmin = await jwennSuperAdminEpiId(data?.createdBy); const superAdmin = await jwennSuperAdminEpiId(data?.createdBy);
@@ -158,6 +158,9 @@
"type": "component", "type": "component",
"component": "kit.lyen", "component": "kit.lyen",
"repeatable": true "repeatable": true
},
"bokanteStatusId": {
"type": "string"
} }
} }
} }
@@ -0,0 +1,75 @@
import {describe, it, expect, vi, afterEach} from 'vitest';
import {backfillBokanteMirror} from '../backfill-bokante-mirrors.js';
const bokanteMastodon = require('../bokante-mastodon.js');
function buildStrapi({paroles = []} = {}) {
const updateMany = vi.fn();
return {
db: {
query: () => ({
findMany: vi.fn(async ({where, orderBy, limit}) => {
expect(where).toEqual({
publishedAt: {$notNull: true},
bokanteStatusId: {$null: true}
});
expect(orderBy).toEqual({publishedAt: 'asc'});
expect(limit).toBe(1);
return paroles.slice(0, limit);
}),
updateMany
})
},
log: {error: vi.fn(), info: vi.fn()}
};
}
describe('backfillBokanteMirror', () => {
const originalCreateStatus = bokanteMastodon.createStatus;
afterEach(() => {
bokanteMastodon.createStatus = originalCreateStatus;
});
it('publie le miroir pour la parole publiée la plus ancienne sans bokanteStatusId', async () => {
bokanteMastodon.createStatus = vi.fn(async () => ({id: '112233'}));
const parole = {documentId: 'doc-1', titre: 'Vieux titre', slug: 'vieux-titre'};
const strapi = buildStrapi({paroles: [parole]});
const result = await backfillBokanteMirror({strapi});
expect(bokanteMastodon.createStatus).toHaveBeenCalledTimes(1);
expect(bokanteMastodon.createStatus.mock.calls[0][0]).toContain('Vieux titre');
expect(bokanteMastodon.createStatus.mock.calls[0][0]).not.toContain('nouvelle parole');
expect(strapi.db.query().updateMany).toHaveBeenCalledWith({
where: {documentId: 'doc-1'},
data: {bokanteStatusId: '112233'}
});
expect(result).toEqual({backfilled: true, slug: 'vieux-titre'});
});
it('ne fait rien si le catalogue est déjà rattrapé', async () => {
bokanteMastodon.createStatus = vi.fn();
const strapi = buildStrapi({paroles: []});
const result = await backfillBokanteMirror({strapi});
expect(bokanteMastodon.createStatus).not.toHaveBeenCalled();
expect(result).toEqual({backfilled: false});
});
it('n\'interrompt rien si bokante échoue, et ne marque pas la parole comme traitée', async () => {
bokanteMastodon.createStatus = vi.fn(async () => {
throw new Error('boom');
});
const parole = {documentId: 'doc-1', titre: 'Titre', slug: 'titre'};
const strapi = buildStrapi({paroles: [parole]});
const result = await backfillBokanteMirror({strapi});
expect(strapi.log.error).toHaveBeenCalledWith(expect.stringContaining('titre'));
expect(strapi.db.query().updateMany).not.toHaveBeenCalled();
expect(result).toEqual({backfilled: false});
});
});
@@ -0,0 +1,73 @@
import {describe, it, expect, vi, afterEach, beforeEach} from 'vitest';
import {createStatus, getStatusContext} from '../bokante-mastodon.js';
describe('bokante-mastodon', () => {
const originalFetch = global.fetch;
const originalEnv = {...process.env};
beforeEach(() => {
process.env.BOKANTE_INSTANCE_URL = 'https://bokante.o-k-i.net';
process.env.BOKANTE_ACCESS_TOKEN = 'test-token';
});
afterEach(() => {
global.fetch = originalFetch;
process.env = {...originalEnv};
vi.restoreAllMocks();
});
describe('createStatus', () => {
it('poste le statut avec le bon endpoint, jeton et corps', async () => {
global.fetch = vi.fn(async () => ({
ok: true,
json: async () => ({id: '112233', url: 'https://bokante.o-k-i.net/@paroles/112233'})
}));
const result = await createStatus('Nouvelle parole : "Titre"');
const [url, options] = global.fetch.mock.calls[0];
expect(url).toBe('https://bokante.o-k-i.net/api/v1/statuses');
expect(options.method).toBe('POST');
expect(options.headers.Authorization).toBe('Bearer test-token');
expect(JSON.parse(options.body)).toEqual({
status: 'Nouvelle parole : "Titre"',
visibility: 'public'
});
expect(result).toEqual({id: '112233', url: 'https://bokante.o-k-i.net/@paroles/112233'});
});
it('lève une erreur claire sur réponse HTTP non-ok', async () => {
global.fetch = vi.fn(async () => ({
ok: false,
status: 422,
text: async () => 'Validation Failed'
}));
await expect(createStatus('texte')).rejects.toThrow('Mastodon 422: Validation Failed');
});
});
describe('getStatusContext', () => {
it('récupère le contexte du statut avec le bon endpoint et jeton', async () => {
const context = {ancestors: [], descendants: [{id: '1', content: '<p>coucou</p>'}]};
global.fetch = vi.fn(async () => ({ok: true, json: async () => context}));
const result = await getStatusContext('112233');
const [url, options] = global.fetch.mock.calls[0];
expect(url).toBe('https://bokante.o-k-i.net/api/v1/statuses/112233/context');
expect(options.headers.Authorization).toBe('Bearer test-token');
expect(result).toEqual(context);
});
it('lève une erreur claire sur réponse HTTP non-ok', async () => {
global.fetch = vi.fn(async () => ({
ok: false,
status: 404,
text: async () => 'Record not found'
}));
await expect(getStatusContext('inconnu')).rejects.toThrow('Mastodon 404: Record not found');
});
});
});
@@ -0,0 +1,131 @@
import {describe, it, expect, vi, afterEach} from 'vitest';
import {importBokanteComments} from '../import-bokante-comments.js';
const bokanteMastodon = require('../bokante-mastodon.js');
function buildStatus(overrides = {}) {
return {
id: 'status-1',
content: '<p>Trè bèl parol !</p>',
created_at: '2026-07-04T12:00:00.000Z',
url: 'https://bokante.o-k-i.net/@quelqun/status-1',
account: {
display_name: 'Quelqu\'un',
acct: 'quelqun@mastodon.social',
avatar: 'https://mastodon.social/avatars/quelqun.png',
url: 'https://mastodon.social/@quelqun'
},
...overrides
};
}
function buildStrapi({paroles, existingRemoteIds = [], createImpl}) {
const commentaireDocuments = {
create: createImpl || vi.fn(async ({data}) => ({id: Math.floor(Math.random() * 10_000), ...data}))
};
const paroleDocuments = {update: vi.fn(async () => ({}))};
return {
db: {
query: uid => {
if (uid === 'api::parole.parole') {
return {findMany: vi.fn(async () => paroles)};
}
if (uid === 'api::commentaire.commentaire') {
return {
findOne: vi.fn(async ({where}) => (existingRemoteIds.includes(where.remoteId) ? {id: 1} : null))
};
}
return {};
}
},
documents: uid => {
if (uid === 'api::commentaire.commentaire') return commentaireDocuments;
if (uid === 'api::parole.parole') return paroleDocuments;
return {};
},
log: {error: vi.fn()}
};
}
describe('importBokanteComments', () => {
const originalGetStatusContext = bokanteMastodon.getStatusContext;
afterEach(() => {
bokanteMastodon.getStatusContext = originalGetStatusContext;
});
it('importe les nouveaux commentaires et les connecte à la parole', async () => {
bokanteMastodon.getStatusContext = vi.fn(async () => ({descendants: [buildStatus()]}));
const paroles = [{id: 7, documentId: 'doc-7', slug: 'mon-titre', bokanteStatusId: '112233'}];
const strapi = buildStrapi({paroles});
const result = await importBokanteComments({strapi});
expect(bokanteMastodon.getStatusContext).toHaveBeenCalledWith('112233');
expect(strapi.documents('api::commentaire.commentaire').create).toHaveBeenCalledWith({
data: expect.objectContaining({
contenu: 'Trè bèl parol !',
origine: 'activitypub',
auteurNom: 'Quelqu\'un',
auteurHandle: '@quelqun@mastodon.social',
auteurAvatarUrl: 'https://mastodon.social/avatars/quelqun.png',
auteurProfilUrl: 'https://mastodon.social/@quelqun',
remoteId: 'status-1',
remoteUrl: 'https://bokante.o-k-i.net/@quelqun/status-1',
parole: 7
})
});
expect(strapi.documents('api::parole.parole').update).toHaveBeenCalledWith({
documentId: 'doc-7',
data: {commentaires: {connect: expect.any(Array)}}
});
expect(result.imported).toBe(1);
});
it('n\'importe pas un commentaire déjà présent (déduplication par remoteId)', async () => {
bokanteMastodon.getStatusContext = vi.fn(async () => ({descendants: [buildStatus({id: 'deja-la'})]}));
const paroles = [{id: 7, documentId: 'doc-7', slug: 'mon-titre', bokanteStatusId: '112233'}];
const strapi = buildStrapi({paroles, existingRemoteIds: ['deja-la']});
const result = await importBokanteComments({strapi});
expect(strapi.documents('api::commentaire.commentaire').create).not.toHaveBeenCalled();
expect(strapi.documents('api::parole.parole').update).not.toHaveBeenCalled();
expect(result.imported).toBe(0);
});
it('ignore les statuts marqués sensitive', async () => {
bokanteMastodon.getStatusContext = vi.fn(async () => ({descendants: [buildStatus({sensitive: true})]}));
const paroles = [{id: 7, documentId: 'doc-7', slug: 'mon-titre', bokanteStatusId: '112233'}];
const strapi = buildStrapi({paroles});
const result = await importBokanteComments({strapi});
expect(strapi.documents('api::commentaire.commentaire').create).not.toHaveBeenCalled();
expect(result.imported).toBe(0);
});
it('continue les autres paroles quand une requête bokante échoue', async () => {
bokanteMastodon.getStatusContext = vi.fn(async statusId => {
if (statusId === 'echoue') {
throw new Error('Mastodon 500: boom');
}
return {descendants: [buildStatus({id: 'ok-1'})]};
});
const paroles = [
{id: 1, documentId: 'doc-1', slug: 'echoue', bokanteStatusId: 'echoue'},
{id: 2, documentId: 'doc-2', slug: 'reussi', bokanteStatusId: 'ok'}
];
const strapi = buildStrapi({paroles});
const result = await importBokanteComments({strapi});
expect(strapi.log.error).toHaveBeenCalledWith(expect.stringContaining('echoue'));
expect(result.imported).toBe(1);
});
});
+36
View File
@@ -0,0 +1,36 @@
'use strict';
const bokanteMastodon = require('./bokante-mastodon');
async function backfillBokanteMirror({strapi}) {
const [parole] = await strapi.db.query('api::parole.parole').findMany({
where: {
publishedAt: {$notNull: true},
bokanteStatusId: {$null: true}
},
orderBy: {publishedAt: 'asc'},
limit: 1
});
if (!parole) {
return {backfilled: false};
}
try {
const status = await bokanteMastodon.createStatus(
`"${parole.titre}" — à (re)découvrir sur pawol.nu\n${process.env.WEBSITE_URL}/paroles/${parole.slug}`
);
await strapi.db.query('api::parole.parole').updateMany({
where: {documentId: parole.documentId},
data: {bokanteStatusId: status.id}
});
return {backfilled: true, slug: parole.slug};
} catch (err) {
strapi.log.error(`Backfill bokante (${parole.slug}) : ${err.message}`);
return {backfilled: false};
}
}
module.exports = {backfillBokanteMirror};
+44
View File
@@ -0,0 +1,44 @@
'use strict';
function getConfig() {
return {
instanceUrl: process.env.BOKANTE_INSTANCE_URL || 'https://bokante.o-k-i.net',
accessToken: process.env.BOKANTE_ACCESS_TOKEN
};
}
async function mastodonFetch(url, options) {
const {accessToken} = getConfig();
const response = await fetch(url, {
...options,
headers: {
Authorization: `Bearer ${accessToken}`,
...options.headers
}
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Mastodon ${response.status}: ${body}`);
}
return response.json();
}
async function createStatus(text, {visibility = 'public'} = {}) {
const {instanceUrl} = getConfig();
return mastodonFetch(`${instanceUrl}/api/v1/statuses`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({status: text, visibility})
});
}
async function getStatusContext(statusId) {
const {instanceUrl} = getConfig();
return mastodonFetch(`${instanceUrl}/api/v1/statuses/${statusId}/context`, {
method: 'GET'
});
}
module.exports = {createStatus, getStatusContext};
+69
View File
@@ -0,0 +1,69 @@
'use strict';
const bokanteMastodon = require('./bokante-mastodon');
function stripHtml(html) {
return (html || '').replace(/<[^>]*>/g, '').trim();
}
async function importBokanteComments({strapi}) {
const paroles = await strapi.db.query('api::parole.parole').findMany({
where: {bokanteStatusId: {$notNull: true}}
});
let imported = 0;
for (const parole of paroles) {
let context;
try {
context = await bokanteMastodon.getStatusContext(parole.bokanteStatusId);
} catch (err) {
strapi.log.error(`Import bokante (${parole.slug}) : ${err.message}`);
continue;
}
const newCommentIds = [];
for (const status of context.descendants || []) {
if (status.sensitive) {
continue;
}
const exists = await strapi.db.query('api::commentaire.commentaire').findOne({
where: {remoteId: status.id}
});
if (exists) {
continue;
}
const commentaire = await strapi.documents('api::commentaire.commentaire').create({
data: {
contenu: stripHtml(status.content),
datePublication: status.created_at,
origine: 'activitypub',
auteurNom: status.account?.display_name,
auteurHandle: status.account?.acct ? `@${status.account.acct}` : undefined,
auteurAvatarUrl: status.account?.avatar,
auteurProfilUrl: status.account?.url,
remoteId: status.id,
remoteUrl: status.url,
parole: parole.id
}
});
newCommentIds.push(commentaire.id);
imported += 1;
}
if (newCommentIds.length > 0) {
await strapi.documents('api::parole.parole').update({
documentId: parole.documentId,
data: {commentaires: {connect: newCommentIds}}
});
}
}
return {imported};
}
module.exports = {importBokanteComments};
+10
View File
@@ -488,6 +488,10 @@ export interface ApiCommentaireCommentaire extends Struct.CollectionTypeSchema {
draftAndPublish: true; draftAndPublish: true;
}; };
attributes: { attributes: {
auteurAvatarUrl: Schema.Attribute.String;
auteurHandle: Schema.Attribute.String;
auteurNom: Schema.Attribute.String;
auteurProfilUrl: Schema.Attribute.String;
contenu: Schema.Attribute.RichText & Schema.Attribute.Required; contenu: Schema.Attribute.RichText & Schema.Attribute.Required;
createdAt: Schema.Attribute.DateTime; createdAt: Schema.Attribute.DateTime;
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
@@ -499,8 +503,13 @@ export interface ApiCommentaireCommentaire extends Struct.CollectionTypeSchema {
'api::commentaire.commentaire' 'api::commentaire.commentaire'
> & > &
Schema.Attribute.Private; Schema.Attribute.Private;
origine: Schema.Attribute.Enumeration<['local', 'activitypub']> &
Schema.Attribute.Required &
Schema.Attribute.DefaultTo<'local'>;
parole: Schema.Attribute.Relation<'oneToOne', 'api::parole.parole'>; parole: Schema.Attribute.Relation<'oneToOne', 'api::parole.parole'>;
publishedAt: Schema.Attribute.DateTime; publishedAt: Schema.Attribute.DateTime;
remoteId: Schema.Attribute.String & Schema.Attribute.Unique;
remoteUrl: Schema.Attribute.String;
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;
@@ -526,6 +535,7 @@ export interface ApiParoleParole extends Struct.CollectionTypeSchema {
attributes: { attributes: {
annee: Schema.Attribute.Integer; annee: Schema.Attribute.Integer;
artistes: Schema.Attribute.Relation<'manyToMany', 'api::artiste.artiste'>; artistes: Schema.Attribute.Relation<'manyToMany', 'api::artiste.artiste'>;
bokanteStatusId: Schema.Attribute.String;
commentaires: Schema.Attribute.Relation< commentaires: Schema.Attribute.Relation<
'oneToMany', 'oneToMany',
'api::commentaire.commentaire' 'api::commentaire.commentaire'