diff --git a/.env.dist b/.env.dist index 96900002..4fb658e7 100644 --- a/.env.dist +++ b/.env.dist @@ -18,6 +18,9 @@ LDAP_USER= LDAP_PWD= ANNAL_UPLOAD_DIR=uploads/exams/ +MEDIA_UPLOAD_DIR=uploads/media +MEDIA_DETACHED_LIFESPAN=1 + CAS_URL=https://cas.utt.fr/cas CAS_SERVICE=http://localhost:8080 diff --git a/.env.test.dist b/.env.test.dist index 0ee7f187..e2409acd 100644 --- a/.env.test.dist +++ b/.env.test.dist @@ -14,6 +14,9 @@ PAGINATION_PAGE_SIZE=20 FAKER_SEED=42 ANNAL_UPLOAD_DIR=uploads/exams/ +MEDIA_UPLOAD_DIR=uploads/media +MEDIA_DETACHED_LIFESPAN=1 + CAS_URL=https://cas.utt.fr/cas CAS_SERVICE=https://etu.utt.fr/login LDAP_URL=ldap://localhost:3002 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 52ebd9c6..907c08ca 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -50,6 +50,31 @@ jobs: - run: pnpm prisma generate - run: pnpm build + docs: + runs-on: self-hosted + strategy: + matrix: + node-version: [22] + pnpm-version: [10] + steps: + - uses: actions/checkout@v5 + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ matrix.pnpm-version }} + - name: Use Node.js + uses: actions/setup-node@v6 + with: + package-manager-cache: false + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + - name: Use Python + uses: actions/setup-python@v6 + with: + python-version: '3.14' + - run: pnpm build:docs:configure + - run: pnpm build:docs + test: runs-on: self-hosted strategy: diff --git a/docs/conf.py b/docs/conf.py index 97d25c49..1b94680b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,7 +18,7 @@ # -- Project information ----------------------------------------------------- project = 'EtuUTT' -copyright = '2024, UTT Net Group' +copyright = '2025, UTT Net Group' author = 'UTT Net Group' @@ -52,7 +52,14 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'sphinx_rtd_theme' +html_theme = 'shibuya' +html_theme_options = { + "accent_color": "blue", + "github_url": "https://github.com/ungdev/etu-utt-api", + "announcement": "
Le site étu est toujours en cours de développement : la documentation est en cours d'écriture et peux être incomplète/incorrecte.
", +} +html_favicon = "logo.svg" +html_logo = "logo.svg" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, @@ -64,4 +71,5 @@ '.md': 'markdown', } -myst_enable_extensions = ["attrs_inline"] \ No newline at end of file +myst_enable_extensions = ["attrs_inline"] +myst_heading_anchors = 4 \ No newline at end of file diff --git a/docs/doc_developers/api/index.md b/docs/doc_developers/api/index.md index 2950cccd..b1b0ffbb 100644 --- a/docs/doc_developers/api/index.md +++ b/docs/doc_developers/api/index.md @@ -1,4 +1,4 @@ -# Documentation développeurs - API +# API Cette documentation s'adresse aux développeurs de l'API de EtuUTT. Elle traite des aspects techniques la concernant : outils utilisés, outils développés, choix faits, ... @@ -10,6 +10,11 @@ setup.md nestjs.md conventions.md test.md +documentation.md +errors.md +permissions.md +lexical.md ues.md timetables.md +scripts.md ``` diff --git a/docs/doc_developers/api/lexical.md b/docs/doc_developers/api/lexical.md new file mode 100644 index 00000000..a4ce5b3d --- /dev/null +++ b/docs/doc_developers/api/lexical.md @@ -0,0 +1,88 @@ +# Texte formatable + +```{danger} +*Le texte formatable est un **sujet de travail actuel**. Son implémentation est susceptible de changer.* \ +Par ailleurs, il est possible que cette fonctionnalité soit séparée dans un package nodejs indépendant. +``` + +A certains endroits du site étu, les utilisateurs pourront formatter du texte. Nous avons décidé de nous baser sur une bibliothèque, [lexical](https://lexical.dev) ([github](https://github.com/facebook/lexical)). Bien que l'utilisateur puisse envoyer du contenu formatté sur à peu près n'importe quel champ texte de l'api (à condition de respecter la limite de caractères), il n'y a que quelques champs qui supportent réellement le texte formatté. + +```{warning} +Les champs qui ne supportent pas le texte formatté acceptent **pour le moment** des inputs formattés. Cependant, ces champs ne seront pas traités comme telles par les applications front-end (comme le front du site étu). Cela peut donner lieu à un affichage considéré comme buggé si la feature est mal utilisée par certaines implémentations front-end ! +``` + +## Abbréviations dans ce document + +Nous utiliserons dans ce documents les abbréviations/anglicismes suivantes : + +- RTE: Rich Text Editor, l'éditeur de texte dans lequel l'utilisateur peut formater du texte +- Renderer: côté serveur, la fonction qui transforme du contenu lexical en html +- Document Object Model (DOM): la structure d'un document, généralement HTML. On l'utilisera ici pour parler de tout ce qui est relatif à l'affichage du texte formatable. + +## Les bundles + +Un bundle est la définition de l'ensemble des contenus supportés par une zone de texte formatable. Il contient des [nodes](#node) et, pour le front, des [plugins](#plugin) et des [composants de toolbar](#composant-de-toolbar). +La définition d'un bundle permet de configurer plusieurs RTE ou Renderers de manière identique. + +Détaillons maintenant les différents éléments contenu dans un bundle : + +### Node + +Une node est une partie du texte ou un élément visuel que l'utilisateur peut ajouter à son texte. Il peut s'agir par exemple d'images, d'émojis customs, ou de texte avec un fond coloré. +Pour créer de nouvelles capacités/de nouveaux éléments dans le texte formatté, la première étape est de créer une `class` qui hérite de `ElementNode`, `TextNode` ou `DecoratorNode`. Pour voir les méthodes à implémenter, regarde [la doc de lexical](https://lexical.dev/docs/concepts/nodes#creating-custom-nodes). Comme indiqué plus bas dans la doc, tu peux utiliser [`$config` et ne pas implémenter les 3 fonctions statiques](https://lexical.dev/docs/concepts/nodes#extending-elementnode-with-config). + +```{tip} +Seule les `DecoratorNode` pourront être rendues avec une ReactNode côté front (cf. `DecoratorNode#decorate`). Si tu n'as pas envie d'écrire des opérations DOM, cela peut être une bonne solution 😉 +``` + +Lorsque tu appliques des classes à des éléments, fais bien attention à prendre celles du thème de l'éditeur, `editor.theme[className]`. Tu pourrais sinon oublier de les implémenter de l'autre côté (serveur/client) et le rendu serait alors différent ! + +Si tu souhaites remplacer une node déjà définie, tu peux enregistrer des [remplacements](https://lexical.dev/docs/concepts/node-replacement). Cela t'évitera de devoir copier-coller tout son code, et à chaque fois qu'une node remplacée sera créée, elle sera transformée en ta nouvelle node à l'aide de la fonction que tu auras définie ! + +### Plugin + +Les plugins n'existent que côté front-end. Ce sont eux qui vont ajouter de la logique, tu vas pouvoir: + +- écouter des [évènements](https://lexical.dev/docs/concepts/commands), à l'aide de `editor.registerCommand()` +- enregistrer des [transformers](https://lexical.dev/docs/concepts/transforms) avec `editor.registerNodeTransform()` +- en react, utiliser `useEffect` +- rendre un élément supplémentaire, un overlay par exemple + +```{admonition} TLDR +:class: tip +Un transformer, c'est du code qui va te permettre de transformer du contenu dans le RTE au fur et à mesure qu'il est ajouté. Par exemple, tu peux transformer automatiquement `:joy:` en l'émoji correspondant, 😂. +``` + +### Composant de toolbar + +Ce qui permettra à l'utilisateur d'intéragir avec tes nodes depuis une interface. + +A l'heure actuelle, ces composants n'existent pas encore en tant que tel et font partie du composant `ToolbarPlugin`. Leur future séparation permettra d'ajouter facilement des composants (boutons, etc) custom dans cette toolbar. + +## Côté serveur + +Bien que le serveur n'ait pas à gérer l'édition en temps réel, il doit pouvoir effectuer quelques opérations sur le contenu généré par l'utilisateur. Cela inclut la validation du contenu lexical et l'export en HTML (pour pouvoir l'utiliser dans des emails par exemple). + +```{admonition} Structure Lexical +La structure envoyée entre le client est le front est un JSON (créé avec `LexicalNode#exportJSON`). Il contient les nodes imbriquées les unes dans les autres. +``` + +### La validation + +La validation vérifie que le contenu fourni utilisateur a bien une structure comprise par le serveur. Cela vérifie : + +- que c'est bien du JSON +- que l'éditeur lexical le comprend +- qu'il n'y a pas de node absente du bundle +- qu'il n'y a pas de propriétés inconnues sur les nodes + +```{attention} +Les propriétés des nodes doivent apparaitre dans le même sens lors de l'exportJSON côté front et côté serveur. +``` + +### Le rendu + +Le rendu permet au serveur de transformer du contenu lexical en html. Il faut faire attention à deux points ici : + +- les méthodes `DecoratorNode#decorate` doivent être enlevées et le contenu de la node doit être traduit en instructions DOM dans `DecoratorNode#createDOM` +- les styles appliqués sur le front doivent être réappliqués sur le DOM produit sur l'API. Cela peut nécessiter des modifications structurelles dans l'HTML généré. En cas de besoin, il est possible d'ajouter des conditions spécifiques dans `NodeStyleInjector.ts` diff --git a/docs/doc_developers/api/nestjs.md b/docs/doc_developers/api/nestjs.md index 345f35d7..3abf8333 100644 --- a/docs/doc_developers/api/nestjs.md +++ b/docs/doc_developers/api/nestjs.md @@ -228,7 +228,7 @@ export class DummyModule {} ``` ```{tip} -L'API de EtuUTT utilise des guards personnalisés, ils sont listés [ici](#outils-specifiques-de-etuutt). +L'API de EtuUTT utilise des guards personnalisés, ils sont listés [ici](#outils-spécifiques-du-site-étu). ``` ### Les interceptors [_(docs)_](https://docs.nestjs.com/interceptors) @@ -292,7 +292,7 @@ Voici une illustration qui permet de récapituler les différentes étapes dans ### La gestion des erreurs -Nous avons implémenté des messages d'erreurs customisés, voir la page sur [la gestion des erreurs](). +Nous avons implémenté des messages d'erreurs customisés, voir la page sur [la gestion des erreurs](errors.md). ### L'authentification nécessaire par défaut diff --git a/docs/doc_developers/api/setup.md b/docs/doc_developers/api/setup.md index 3c8025b1..82112afc 100644 --- a/docs/doc_developers/api/setup.md +++ b/docs/doc_developers/api/setup.md @@ -171,14 +171,10 @@ Les tests sont tous lancés. Au bout de quelques secondes / minutes, tous les te Pour cela, vous devez avoir Python3 installé. -Il faut commencer par installer les dépendances : - ```bash -# Pour les commandes pip, il est possible d'utiliser python -m pip (ou python3 -m pip) à la place de pip. -cd docs -pip install --upgrade pip setuptools sphinx readthedocs-sphinx-ext -pip install -r docs/requirements.txt -python -m sphinx -T -b html -d _build/doctrees -D language=fr . build/html +# Pour la première commande, il faut la faire uniquement la première fois, elle va installer les dépendances python +pnpm build:docs:configure +pnpm build:docs ``` Le résultat du build se situe alors dans `docs/build/html`. Le fichier racine est `index.html`. Le résultat de la diff --git a/docs/doc_developers/front/index.md b/docs/doc_developers/front/index.md index 3b908059..424d5fee 100644 --- a/docs/doc_developers/front/index.md +++ b/docs/doc_developers/front/index.md @@ -1,4 +1,4 @@ -# Documentation développeurs - Site web +# Site web Cette documentation s'adresse aux développeurs du site web de EtuUTT. Elle traite des aspects techniques le concernant : outils utilisés, outils développés, choix faits, ... diff --git a/docs/doc_developers/index.md b/docs/doc_developers/index.md index bf45e07e..bc9bc5fc 100644 --- a/docs/doc_developers/index.md +++ b/docs/doc_developers/index.md @@ -1,4 +1,4 @@ -# Documentation développeurs +# Développeurs Cette documentation s'adresse aux développeurs de EtuUTT. Elle traite des aspects techniques de l'API, de l'application mobile et du site web : outils utilisés, outils développés, choix faits, ... diff --git a/docs/doc_developers/mobile_app/index.md b/docs/doc_developers/mobile_app/index.md index ad04e552..d199d803 100644 --- a/docs/doc_developers/mobile_app/index.md +++ b/docs/doc_developers/mobile_app/index.md @@ -1,4 +1,4 @@ -# Documentation EtuUTT - Application mobile +# Application mobile Cette documentation s'adresse aux développeurs de l'appli MyUTT. Elle traite des aspects techniques la concernant : outils utilisés, outils développés, choix faits, ... diff --git a/docs/logo.svg b/docs/logo.svg new file mode 100644 index 00000000..19aea1ff --- /dev/null +++ b/docs/logo.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/docs/requirements.txt b/docs/requirements.txt index c00cdf64..268bb648 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,3 @@ myst-parser sphinx -sphinx-rtd-theme \ No newline at end of file +shibuya diff --git a/package.json b/package.json index 4c3b2073..0737f38e 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "license": "UNLICENSED", "scripts": { "build": "npx nest build", + "build:docs": "python3 -m sphinx -W -T -b html -d docs/build/doctrees -D language=fr docs docs/build/html", + "build:docs:configure": "pip install --upgrade pip setuptools sphinx readthedocs-sphinx-ext && pip install -r docs/requirements.txt", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "start": "NODE_ENV=development npx nest start", "start:dev": "NODE_ENV=development npx nest start --watch", @@ -37,6 +39,14 @@ }, "dependencies": { "@fast-csv/parse": "^5.0.2", + "@lexical/code": "^0.37.0", + "@lexical/extension": "^0.37.0", + "@lexical/headless": "^0.37.0", + "@lexical/html": "^0.37.0", + "@lexical/link": "^0.37.0", + "@lexical/list": "^0.37.0", + "@lexical/rich-text": "^0.37.0", + "@lexical/table": "^0.37.0", "@nestjs/axios": "^4.0.0", "@nestjs/common": "^11.0.11", "@nestjs/config": "^4.0.1", @@ -53,6 +63,7 @@ "fast-xml-parser": "^5.0.9", "file-type": "^20.4.1", "ldapts": "^7.3.1", + "lexical": "^0.37.0", "multer": "1.4.5-lts.1", "pactum-matchers": "^1.1.7", "passport-jwt": "^4.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ce103e8..e4038310 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,30 @@ importers: '@fast-csv/parse': specifier: ^5.0.2 version: 5.0.2 + '@lexical/code': + specifier: ^0.37.0 + version: 0.37.0 + '@lexical/extension': + specifier: ^0.37.0 + version: 0.37.0 + '@lexical/headless': + specifier: ^0.37.0 + version: 0.37.0 + '@lexical/html': + specifier: ^0.37.0 + version: 0.37.0 + '@lexical/link': + specifier: ^0.37.0 + version: 0.37.0 + '@lexical/list': + specifier: ^0.37.0 + version: 0.37.0 + '@lexical/rich-text': + specifier: ^0.37.0 + version: 0.37.0 + '@lexical/table': + specifier: ^0.37.0 + version: 0.37.0 '@nestjs/axios': specifier: ^4.0.0 version: 4.0.0(@nestjs/common@11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.8.3)(rxjs@7.8.2) @@ -34,7 +58,7 @@ importers: version: 11.1.2(@nestjs/common@11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.2) '@nestjs/swagger': specifier: ^11.0.6 - version: 11.2.0(@nestjs/common@11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.2)(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) + version: 11.2.0(@nestjs/common@11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.2(@nestjs/common@11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) '@prisma/client': specifier: ^6.5.0 version: 6.8.2(prisma@6.8.2(typescript@5.8.3))(typescript@5.8.3) @@ -59,6 +83,9 @@ importers: ldapts: specifier: ^7.3.1 version: 7.3.1 + lexical: + specifier: ^0.37.0 + version: 0.37.0 multer: specifier: 1.4.5-lts.1 version: 1.4.5-lts.1 @@ -95,7 +122,7 @@ importers: version: 11.0.5(chokidar@4.0.3)(typescript@5.8.3) '@nestjs/testing': specifier: ^11.0.11 - version: 11.1.2(@nestjs/common@11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.2)(@nestjs/platform-express@11.1.2) + version: 11.1.2(@nestjs/common@11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.2(@nestjs/common@11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.2(@nestjs/common@11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.2)) '@types/bcryptjs': specifier: ^3.0.0 version: 3.0.0 @@ -158,7 +185,7 @@ importers: version: 2.18.1 nestjs-spelunker: specifier: ^1.3.2 - version: 1.3.2(@nestjs/common@11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.2) + version: 1.3.2(@nestjs/common@11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.2(@nestjs/common@11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)) nock: specifier: ^14.0.1 version: 14.0.4 @@ -831,6 +858,42 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@lexical/clipboard@0.37.0': + resolution: {integrity: sha512-hRwASFX/ilaI5r8YOcZuQgONFshRgCPfdxfofNL7uruSFYAO6LkUhsjzZwUgf0DbmCJmbBADFw15FSthgCUhGA==} + + '@lexical/code@0.37.0': + resolution: {integrity: sha512-ZXA4j/S8yLrxjrTnEp39VeDMp4Rd8bLYUlT4Buy1MQlS1WafxOiMhNQJG7k0BP/pO96YPkAebpA81ATKJL0IgA==} + + '@lexical/dragon@0.37.0': + resolution: {integrity: sha512-iC4OKivEPtt7cGVSwZylLfz5T7Oqr9q9EOosS6E/byMyoqwkYWGjXn/qFiwIv1Xo3+G19vhfChi/+ZcYLXpHPw==} + + '@lexical/extension@0.37.0': + resolution: {integrity: sha512-Z58f2tIdz9bn8gltUu5cVg37qROGha38dUZv20gI2GeNugXAkoPzJYEcxlI1D/26tkevJ/7VaFUr9PTk+iKmaA==} + + '@lexical/headless@0.37.0': + resolution: {integrity: sha512-oBMlySHwjl9iJA9A3DU8V4xMqyzkt79OnLI9XP0uvDdJeGM2C+SoV44K54bGRkCow/i/XdHB76tsYMD777ObjQ==} + + '@lexical/html@0.37.0': + resolution: {integrity: sha512-oTsBc45eL8/lmF7fqGR+UCjrJYP04gumzf5nk4TczrxWL2pM4GIMLLKG1mpQI2H1MDiRLzq3T/xdI7Gh74z7Zw==} + + '@lexical/link@0.37.0': + resolution: {integrity: sha512-gglkjE99tKYnGAxQbrUq9TcaVKBQhidXhgPPbVw3x1Fba9biMafkbSJhE/7/pzQTPoQBAIl0w7DOUWmBOv+JbQ==} + + '@lexical/list@0.37.0': + resolution: {integrity: sha512-AOC6yAA3mfNvJKbwo+kvAbPJI+13yF2ISA65vbA578CugvJ08zIVgM+pSzxquGhD0ioJY3cXVW7+gdkCP1qu5g==} + + '@lexical/rich-text@0.37.0': + resolution: {integrity: sha512-A9i5Es/RrZv71tB6dDSyd4TYdbkn/+oUrUdTwnWa+B8EZW26q0h+wgxCGwPtTU7ho4JNP9HOot+EIhe2DbyaYg==} + + '@lexical/selection@0.37.0': + resolution: {integrity: sha512-Lix1s2r71jHfsTEs4q/YqK2s3uXKOnyA3fd1VDMWysO+bZzRwEO5+qyDvENZ0WrXSDCnlibNFV1HttWX9/zqyw==} + + '@lexical/table@0.37.0': + resolution: {integrity: sha512-g7S8ml8kIujEDLWlzYKETgPCQ2U9oeWqdytRuHjHGi/rjAAGHSej5IRqTPIMxNP3VVQHnBoQ+Y9hBtjiuddhgQ==} + + '@lexical/utils@0.37.0': + resolution: {integrity: sha512-CFp4diY/kR5RqhzQSl/7SwsMod1sgLpI1FBifcOuJ6L/S6YywGpEB4B7aV5zqW21A/jU2T+2NZtxSUn6S+9gMg==} + '@lukeed/csprng@1.1.0': resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} @@ -1003,6 +1066,9 @@ packages: '@polka/url@0.5.0': resolution: {integrity: sha512-oZLYFEAzUKyi3SKnXvj32ZCEGH6RDnao7COuCVhDydMS9NrCSVXhM79VaKyP5+Zc33m0QXEd2DN3UkU7OsHcfw==} + '@preact/signals-core@1.12.1': + resolution: {integrity: sha512-BwbTXpj+9QutoZLQvbttRg5x3l5468qaV2kufh+51yha1c53ep5dY4kTuZR35+3pAZxpfQerGJiQqg34ZNZ6uA==} + '@prisma/client@6.8.2': resolution: {integrity: sha512-5II+vbyzv4si6Yunwgkj0qT/iY0zyspttoDrL3R4BYgLdp42/d2C8xdi9vqkrYtKt9H32oFIukvyw3Koz5JoDg==} engines: {node: '>=18.18'} @@ -1146,6 +1212,9 @@ packages: '@types/mysql@2.15.26': resolution: {integrity: sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==} + '@types/node@20.19.22': + resolution: {integrity: sha512-hRnu+5qggKDSyWHlnmThnUqg62l29Aj/6vcYgUaSFL9oc7DVjeWEQN3PRgdSc6F8d9QRMWkf36CLMch1Do/+RQ==} + '@types/node@22.13.10': resolution: {integrity: sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==} @@ -1182,6 +1251,9 @@ packages: '@types/validator@13.11.8': resolution: {integrity: sha512-c/hzNDBh7eRF+KbCf+OoZxKbnkpaK/cKp9iLQWqB7muXtM+MtL9SUUH8vCFcLn6dH1Qm05jiexK0ofWY7TfOhQ==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -2214,6 +2286,10 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + happy-dom@20.0.7: + resolution: {integrity: sha512-CywLfzmYxP5OYpuAG0usFY0CpxJtwYR+w8Mms5J8W29Y2Pzf6rbfQS2M523tRZTb0oLA+URopPtnAQX2fupHZQ==} + engines: {node: '>=20.0.0'} + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -2658,6 +2734,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lexical@0.37.0: + resolution: {integrity: sha512-r5VJR2TioQPAsZATfktnJFrGIiy6gjQN8b/+0a2u1d7/QTH7lhbB7byhGSvcq1iaa1TV/xcf/pFV55a5V5hTDQ==} + libphonenumber-js@1.10.54: resolution: {integrity: sha512-P+38dUgJsmh0gzoRDoM4F5jLbyfztkU6PY6eSK6S5HwTi/LPvnwXqVCQZlAy1FxZ5c48q25QhxGQ0pq+WQcSlQ==} @@ -3145,6 +3224,10 @@ packages: typescript: optional: true + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -3726,6 +3809,9 @@ packages: undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + unicode-properties@1.4.1: resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} @@ -3833,6 +3919,10 @@ packages: resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} engines: {node: '>=12'} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-url@14.1.1: resolution: {integrity: sha512-mDGf9diDad/giZ/Sm9Xi2YcyzaFpbdLpJPr+E9fSkyQ7KpQD4SdFcugkRQYzhmfI4KeV4Qpnn2sKPdo+kmsgRQ==} engines: {node: '>=18'} @@ -4664,6 +4754,81 @@ snapshots: '@jridgewell/resolve-uri': 3.1.1 '@jridgewell/sourcemap-codec': 1.4.15 + '@lexical/clipboard@0.37.0': + dependencies: + '@lexical/html': 0.37.0 + '@lexical/list': 0.37.0 + '@lexical/selection': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/code@0.37.0': + dependencies: + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + prismjs: 1.30.0 + + '@lexical/dragon@0.37.0': + dependencies: + '@lexical/extension': 0.37.0 + lexical: 0.37.0 + + '@lexical/extension@0.37.0': + dependencies: + '@lexical/utils': 0.37.0 + '@preact/signals-core': 1.12.1 + lexical: 0.37.0 + + '@lexical/headless@0.37.0': + dependencies: + happy-dom: 20.0.7 + lexical: 0.37.0 + + '@lexical/html@0.37.0': + dependencies: + '@lexical/selection': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/link@0.37.0': + dependencies: + '@lexical/extension': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/list@0.37.0': + dependencies: + '@lexical/extension': 0.37.0 + '@lexical/selection': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/rich-text@0.37.0': + dependencies: + '@lexical/clipboard': 0.37.0 + '@lexical/dragon': 0.37.0 + '@lexical/selection': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/selection@0.37.0': + dependencies: + lexical: 0.37.0 + + '@lexical/table@0.37.0': + dependencies: + '@lexical/clipboard': 0.37.0 + '@lexical/extension': 0.37.0 + '@lexical/utils': 0.37.0 + lexical: 0.37.0 + + '@lexical/utils@0.37.0': + dependencies: + '@lexical/list': 0.37.0 + '@lexical/selection': 0.37.0 + '@lexical/table': 0.37.0 + lexical: 0.37.0 + '@lukeed/csprng@1.1.0': {} '@microsoft/tsdoc@0.15.1': {} @@ -4789,7 +4954,7 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/swagger@11.2.0(@nestjs/common@11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.2)(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)': + '@nestjs/swagger@11.2.0(@nestjs/common@11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.2(@nestjs/common@11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)': dependencies: '@microsoft/tsdoc': 0.15.1 '@nestjs/common': 11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -4804,7 +4969,7 @@ snapshots: class-transformer: 0.5.1 class-validator: 0.14.1 - '@nestjs/testing@11.1.2(@nestjs/common@11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.2)(@nestjs/platform-express@11.1.2)': + '@nestjs/testing@11.1.2(@nestjs/common@11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.2(@nestjs/common@11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.2(@nestjs/common@11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.2))': dependencies: '@nestjs/common': 11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.2(@nestjs/common@11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -4847,6 +5012,8 @@ snapshots: '@polka/url@0.5.0': {} + '@preact/signals-core@1.12.1': {} + '@prisma/client@6.8.2(prisma@6.8.2(typescript@5.8.3))(typescript@5.8.3)': optionalDependencies: prisma: 6.8.2(typescript@5.8.3) @@ -5017,6 +5184,10 @@ snapshots: dependencies: '@types/node': 22.13.10 + '@types/node@20.19.22': + dependencies: + undici-types: 6.21.0 + '@types/node@22.13.10': dependencies: undici-types: 6.20.0 @@ -5060,6 +5231,8 @@ snapshots: '@types/validator@13.11.8': {} + '@types/whatwg-mimetype@3.0.2': {} + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.32': @@ -6198,6 +6371,12 @@ snapshots: graphemer@1.4.0: {} + happy-dom@20.0.7: + dependencies: + '@types/node': 20.19.22 + '@types/whatwg-mimetype': 3.0.2 + whatwg-mimetype: 3.0.0 + has-flag@3.0.0: {} has-flag@4.0.0: {} @@ -6863,6 +7042,8 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lexical@0.37.0: {} + libphonenumber-js@1.10.54: {} lightcookie@1.0.25: {} @@ -7059,7 +7240,7 @@ snapshots: neo-async@2.6.2: {} - nestjs-spelunker@1.3.2(@nestjs/common@11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.2): + nestjs-spelunker@1.3.2(@nestjs/common@11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.2(@nestjs/common@11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)): dependencies: '@nestjs/common': 11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.2(@nestjs/common@11.1.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -7305,6 +7486,8 @@ snapshots: optionalDependencies: typescript: 5.8.3 + prismjs@1.30.0: {} + process-nextick-args@2.0.1: {} prompts@2.4.2: @@ -7900,6 +8083,8 @@ snapshots: undici-types@6.20.0: {} + undici-types@6.21.0: {} + unicode-properties@1.4.1: dependencies: base64-js: 1.5.1 @@ -8022,6 +8207,8 @@ snapshots: dependencies: iconv-lite: 0.6.3 + whatwg-mimetype@3.0.0: {} + whatwg-url@14.1.1: dependencies: tr46: 5.0.0 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ee6bef46..45c75eb1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -52,7 +52,7 @@ model Asso { mail String @unique @db.VarChar(100) phoneNumber String? @db.VarChar(30) website String? @db.VarChar(100) - logo String? @db.VarChar(100) + logoMediaId String? @db.Char(36) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? @@ -60,6 +60,8 @@ model Asso { descriptionTranslationId String? @unique assoAccountId String @unique // User account of the asso + logo ImageMedia? @relation("logo", fields: [logoMediaId], references: [id], onDelete: SetNull) + descriptionImages ImageMedia[] @relation("descriptionImages") descriptionShortTranslation Translation? @relation(name: "descriptionShortTranslation", fields: [descriptionShortTranslationId], references: [id], onDelete: Cascade) descriptionTranslation Translation? @relation(name: "descriptionTranslation", fields: [descriptionTranslationId], references: [id], onDelete: Cascade) assoMemberships AssoMembership[] @@ -162,6 +164,22 @@ model GitHubIssue { user User @relation(fields: [userId], references: [id]) } +model ImageMedia { + id String @id @default(uuid()) @db.Char(36) + size Int @db.UnsignedInt + width Int @db.UnsignedInt + height Int @db.UnsignedInt + uploadedAt DateTime @default(now()) + isPublic Boolean @default(false) + uploaderId String? + preset ImageMediaPreset + + uploader User? @relation(fields: [uploaderId], references: [id]) + avatarForUsers UserInfos[] + logoForAssos Asso[] @relation("logo") + descriptionForAssos Asso[] @relation("descriptionImages") +} + model Semester { code String @id @db.Char(3) start DateTime @db.Date @@ -592,6 +610,7 @@ model User { apiPermissionsTarget ApiKeyPermission[] @relation(name: "target") apiPermissionsGrants ApiKeyPermission[] @relation(name: "granter") asso Asso? + uploadedImages ImageMedia[] } model UserAddress { @@ -662,16 +681,17 @@ model UserFormation { } model UserInfos { - id String @id @default(uuid()) - sex Sex? - nationality String? @db.VarChar(50) - birthday DateTime? @db.Date - avatar String @default("default.png") @db.VarChar(255) - nickname String? @db.VarChar(50) - passions String? @db.Text - website String? @db.VarChar(255) + id String @id @default(uuid()) + sex Sex? + nationality String? @db.VarChar(50) + birthday DateTime? @db.Date + avatarMediaId String? @db.Char(36) + nickname String? @db.VarChar(50) + passions String? @db.Text + website String? @db.VarChar(255) - user User? + user User? + avatar ImageMedia? @relation(fields: [avatarMediaId], references: [id], onDelete: SetNull) } model UserMailsPhones { @@ -899,6 +919,11 @@ enum AddressPrivacy { ALL_PUBLIC } +enum ImageMediaPreset { + AVATAR + CUSTOM +} + enum Permission { API_SEE_OPINIONS_UE // See the rates of an UE API_GIVE_OPINIONS_UE // Rate an UE you have done or are doing @@ -906,6 +931,7 @@ enum Permission { API_UPLOAD_ANNALS // Upload an annal API_MODERATE_ANNALS // Moderate annals API_MODERATE_COMMENTS // Moderate comments + API_UPLOAD_MEDIA // Upload to media enpoints USER_SEE_DETAILS // See personal details about someone, even the ones the user decided to hide USER_UPDATE_DETAILS // Update personal details about someone diff --git a/prisma/seed/modules/asso.seed.ts b/prisma/seed/modules/asso.seed.ts index b806bcd0..30f06d9d 100644 --- a/prisma/seed/modules/asso.seed.ts +++ b/prisma/seed/modules/asso.seed.ts @@ -15,7 +15,29 @@ export default function assoSeed(prisma: PrismaClient) { mail: faker.internet.email(), phoneNumber: faker.phone.number(), website: faker.internet.domainName(), - logo: faker.image.urlLoremFlickr({ category: 'business' }), + logo: { + create: { + height: 100, + width: 100, + size: 1024, + isPublic: true, + preset: 'AVATAR', + uploader: { + create: { + login: name, + firstName: '', + lastName: '', + userType: UserType.ASSOCIATION, + socialNetwork: { create: {} }, + mailsPhones: { create: {} }, + rgpd: { create: {} }, + preference: { create: {} }, + infos: { create: {} }, + privacy: { create: {} }, + }, + }, + }, + }, createdAt: date, updatedAt: date, descriptionShortTranslation: { @@ -42,8 +64,8 @@ export default function assoSeed(prisma: PrismaClient) { preference: { create: {} }, infos: { create: {} }, privacy: { create: {} }, - } - } + }, + }, }, }), ); diff --git a/src/app.module.ts b/src/app.module.ts index a9b8f700..532dab92 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -15,12 +15,14 @@ import { BranchModule } from './branch/branch.module'; import { AssosModule } from './assos/assos.module'; import { TranslationInterceptor } from './app.interceptor'; import { SemesterModule } from './semester/semester.module'; +import { ImageMediaModule } from './media/image/imagemedia.module'; @Module({ imports: [ ConfigModule, HttpModule, PrismaModule, + ImageMediaModule, SemesterModule, AuthModule, ProfileModule, diff --git a/src/assos/assos.controller.ts b/src/assos/assos.controller.ts index f64ee5da..46ca39f4 100644 --- a/src/assos/assos.controller.ts +++ b/src/assos/assos.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Patch, Post, Put, Query } from '@nestjs/ import { ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiAppErrorResponse, paginatedResponseDto } from '../app.dto'; import { AssoMembershipRole } from './interfaces/membership-role.interface'; +import { ImageMediaService } from '../media/image/imagemedia.service'; import { AssoMembership } from './interfaces/membership.interface'; import { ParamAsso } from './decorator/get-asso'; import { GetUser, IsPublic } from '../auth/decorator'; @@ -23,6 +24,9 @@ import AssosMemberCreateReqDto from './dto/req/assos-member-create.dto'; import AssosMemberUpdateReqDto from './dto/req/assos-member-update.dto'; import AssoMembershipResDto from './dto/res/assos-membership-res.dto'; import UsersService from '../users/users.service'; +import AssosUpdateReqDto from './dto/req/assos-update-req.dto'; +import { LexicalModule } from '../lexical/lexical.module'; +import { ImageMediaPreset } from '@prisma/client'; @Controller('assos') @ApiTags('Assos') @@ -30,6 +34,8 @@ export class AssosController { constructor( readonly assosService: AssosService, readonly userService: UsersService, + readonly mediaService: ImageMediaService, + readonly lexicalModule: LexicalModule, ) {} @Get() @@ -56,6 +62,39 @@ export class AssosController { return this.formatAssoDetail(asso); } + @Patch('/:assoId') + @ApiOperation({ + description: 'Update an asso. Only the fields present in the body will be updated.', + }) + @ApiOkResponse({ type: AssoDetailResDto }) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_ASSO, 'There is no asso with the given id') + @ApiAppErrorResponse( + ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, + 'The user has no permission to perform this action for this asso', + ) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_MEDIA, 'The media does not exist') + @ApiAppErrorResponse(ERROR_CODE.MEDIA_NOT_PUBLIC, 'The media is not public') + @ApiAppErrorResponse(ERROR_CODE.MEDIA_PRESET_REQUIRED, 'The media does not have the required preset (avatar)') + async updateAsso( + @ParamAsso() asso: Asso, + @GetUser() user: User, + @Body() body: AssosUpdateReqDto, + ): Promise { + if (!(await this.assosService.hasSomeAssoPermission(asso, user.id, 'manage_infos'))) + throw new AppException(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, 'manage_infos'); + for (const key in body.description) + if (body.description[key] && !this.lexicalModule.isValidLexicalContent(body.description[key])) + throw new AppException(ERROR_CODE.PARAM_LEXICAL_ILLEGAL, `description.${key}`); + if (body.logo) { + const media = await this.mediaService.getMedia(body.logo); + if (!media) throw new AppException(ERROR_CODE.NO_SUCH_MEDIA, body.logo); + if (!media.isPublic) throw new AppException(ERROR_CODE.MEDIA_NOT_PUBLIC); + if (media.preset !== ImageMediaPreset.AVATAR) + throw new AppException(ERROR_CODE.MEDIA_PRESET_REQUIRED, ImageMediaPreset.AVATAR); + } + return this.assosService.updateAsso(asso.id, body).then(this.formatAssoDetail); + } + // The route below is not public as it exposes the full name of all members, only the president is supposed to be exposed publicly in the route above @Get('/:assoId/members') @ApiOperation({ @@ -218,7 +257,8 @@ export class AssosController { formatAssoOverview(asso: Asso): AssoOverviewResDto { return { - ...pick(asso, 'id', 'name', 'logo', 'president'), + ...pick(asso, 'id', 'name', 'president'), + logo: asso.logo ? `/media/image/${asso.logo.id}.webp` : null, shortDescription: asso.descriptionShortTranslation, president: { role: pick(asso.president.role, 'id', 'name'), @@ -229,7 +269,8 @@ export class AssosController { formatAssoDetail(asso: Asso): AssoDetailResDto { return { - ...pick(asso, 'id', 'name', 'mail', 'phoneNumber', 'website', 'logo'), + ...pick(asso, 'id', 'name', 'mail', 'phoneNumber', 'website'), + logo: asso.logo ? `/media/image/${asso.logo.id}.webp` : null, description: asso.descriptionTranslation, president: { role: pick(asso.president.role, 'id', 'name'), diff --git a/src/assos/assos.module.ts b/src/assos/assos.module.ts index bd02ca10..ecbca011 100644 --- a/src/assos/assos.module.ts +++ b/src/assos/assos.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common'; import { AssosController } from './assos.controller'; import { AssosService } from './assos.service'; +import { ImageMediaModule } from '../media/image/imagemedia.module'; +import { LexicalModule } from '../lexical/lexical.module'; import UsersService from '../users/users.service'; /** @@ -10,5 +12,6 @@ import UsersService from '../users/users.service'; @Module({ controllers: [AssosController], providers: [AssosService, UsersService], + imports: [ImageMediaModule, LexicalModule], }) export class AssosModule {} diff --git a/src/assos/assos.service.ts b/src/assos/assos.service.ts index e21b02e0..9d179a3a 100644 --- a/src/assos/assos.service.ts +++ b/src/assos/assos.service.ts @@ -9,6 +9,7 @@ import { AssoMembershipRole } from './interfaces/membership-role.interface'; import AssosSearchReqDto from './dto/req/assos-search-req.dto'; import AssosMemberUpdateReqDto from './dto/req/assos-member-update.dto'; import { AppException, ERROR_CODE } from '../exceptions'; +import AssosUpdateReqDto from './dto/req/assos-update-req.dto'; @Injectable() export class AssosService { @@ -81,6 +82,63 @@ export class AssosService { }); } + async updateAsso(assoId: string, update: AssosUpdateReqDto): Promise { + const updated = await this.prisma.normalize.asso.update({ + where: { id: assoId }, + data: { + ...(update.name ? { name: update.name } : {}), + ...(update.logo ? { logo: { connect: { id: update.logo } } } : {}), + ...(update.descriptionShort ? { descriptionShortTranslation: { update: update.descriptionShort } } : {}), + ...(update.description ? { descriptionTranslation: { update: update.description } } : {}), + ...(update.email ? { mail: update.email } : {}), + ...(update.phoneNumber ? { phoneNumber: update.phoneNumber } : {}), + ...(update.website ? { website: update.website } : {}), + }, + }); + if (update.description) { + // Cleanup unused images + const regex = /"src":"https:\/\/[^"]+\/media\/image\/([^/]+)\.webp"/g; + const imagesInUse = new Set(); + for (const field in updated.descriptionTranslation) + for (const match of (updated.descriptionTranslation[field])?.matchAll(regex) ?? []) + imagesInUse.add(match[1]); + const currentImages = new Set( + ( + await this.prisma.imageMedia.findMany({ + where: { descriptionForAssos: { some: { id: assoId } } }, + select: { id: true }, + }) + ).map((m) => m.id), + ); + const deletions = [...currentImages].filter((x) => !imagesInUse.has(x)); + const additions = [...imagesInUse].filter((x) => !currentImages.has(x)); + const existingAdditionIds = new Set( + ( + await this.prisma.imageMedia.findMany({ + where: { id: { in: additions } }, + select: { id: true }, + }) + ).map((m) => m.id), + ); + if (deletions.length > 0 || additions.length > 0) + await this.prisma.$transaction([ + ...Array.from(deletions).map((id) => + this.prisma.imageMedia.update({ + where: { id }, + data: { descriptionForAssos: { disconnect: { id: assoId } } }, + }), + ), + ...Array.from(additions.filter((x) => existingAdditionIds.has(x))).map((id) => + this.prisma.imageMedia.update({ + where: { id }, + data: { descriptionForAssos: { connect: { id: assoId } } }, + }), + ), + ]); + } + return updated; + } + async getAssoMembers(assoId: string): Promise { return this.prisma.normalize.assoMembershipRole.findMany({ where: { diff --git a/src/assos/dto/req/assos-update-req.dto.ts b/src/assos/dto/req/assos-update-req.dto.ts new file mode 100644 index 00000000..3307a6a6 --- /dev/null +++ b/src/assos/dto/req/assos-update-req.dto.ts @@ -0,0 +1,50 @@ +import { Type } from 'class-transformer'; +import { + IsEmail, + IsNotEmpty, + IsOptional, + IsPhoneNumber, + IsString, + IsUrl, + IsUUID, + ValidateNested, +} from 'class-validator'; +import { TranslatedTextDto } from '../../../utils'; + +export default class AssosUpdateReqDto { + @IsOptional() + @IsString() + @IsNotEmpty() + name?: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + @IsUUID() + logo?: string; + + @IsOptional() + @Type(() => TranslatedTextDto) + @ValidateNested() + descriptionShort?: TranslatedTextDto; + + @IsOptional() + @Type(() => TranslatedTextDto) + @ValidateNested() + description?: TranslatedTextDto; + + @IsOptional() + @IsString() + @IsEmail() + email?: string; + + @IsOptional() + @IsString() + @IsPhoneNumber() + phoneNumber?: string; + + @IsOptional() + @IsString() + @IsUrl() + website?: string; +} diff --git a/src/auth/decorator/index.ts b/src/auth/decorator/index.ts index 4b609c58..90ff7cc2 100644 --- a/src/auth/decorator/index.ts +++ b/src/auth/decorator/index.ts @@ -2,3 +2,4 @@ export * from './get-user.decorator'; export * from './require-permission.decorator'; export * from './public.decorator'; export * from './require-role.decorator'; +export * from './skip-application-check.decorator'; diff --git a/src/auth/decorator/skip-application-check.decorator.ts b/src/auth/decorator/skip-application-check.decorator.ts new file mode 100644 index 00000000..3b13bef1 --- /dev/null +++ b/src/auth/decorator/skip-application-check.decorator.ts @@ -0,0 +1,3 @@ +import { Reflector } from '@nestjs/core'; + +export const SkipApplicationCheck = Reflector.createDecorator(); diff --git a/src/auth/guard/jwt.guard.ts b/src/auth/guard/jwt.guard.ts index 43eb79e0..5235efb7 100644 --- a/src/auth/guard/jwt.guard.ts +++ b/src/auth/guard/jwt.guard.ts @@ -1,27 +1,33 @@ import { ExecutionContext, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; -import { IsPublic } from '../decorator'; +import { IsPublic, SkipApplicationCheck } from '../decorator'; import { AppException, ERROR_CODE } from '../../exceptions'; import { RequestAuthData } from '../interfaces/request-auth-data.interface'; import { PrismaService } from '../../prisma/prisma.service'; import { PermissionManager } from '../../utils'; +import { RawApiApplication } from '../../prisma/types'; @Injectable() export class JwtGuard extends AuthGuard('jwt') { - constructor(private reflector: Reflector, private prisma: PrismaService) { + constructor( + private reflector: Reflector, + private prisma: PrismaService, + ) { super(); } async canActivate(context: ExecutionContext) { const request = context.switchToHttp().getRequest() as { user: RequestAuthData }; + const canSkipApplicationHeader = this.reflector.get(SkipApplicationCheck, context.getHandler()); const applicationId = context.switchToHttp().getRequest().headers['x-application']; - if (!applicationId) throw new AppException(ERROR_CODE.APPLICATION_HEADER_MISSING); - const application = await this.prisma.apiApplication.findUnique({ - where: { id: applicationId }, - }); - if (!application) { - throw new AppException(ERROR_CODE.NO_SUCH_APPLICATION, applicationId); + let application: RawApiApplication | null = null; + if (!applicationId && !canSkipApplicationHeader) throw new AppException(ERROR_CODE.APPLICATION_HEADER_MISSING); + else if (applicationId) { + application = await this.prisma.apiApplication.findUnique({ + where: { id: applicationId }, + }); + if (!application) throw new AppException(ERROR_CODE.NO_SUCH_APPLICATION, applicationId); } // Check whether the user is logged in let loggedIn = true; diff --git a/src/config/config.module.ts b/src/config/config.module.ts index 614c5b39..75245e5e 100644 --- a/src/config/config.module.ts +++ b/src/config/config.module.ts @@ -31,6 +31,8 @@ export class ConfigModule { public readonly IS_PROD_ENV: boolean; public readonly TIMETABLE_URL: string; public readonly ANNAL_UPLOAD_DIR: string; + public readonly MEDIA_UPLOAD_DIR: string; + public readonly MEDIA_DETACHED_LIFESPAN: number; public readonly ETUUTT_WEBSITE_APPLICATION_ID: string; // DEV ENVIRONMENT ONLY @@ -49,10 +51,13 @@ export class ConfigModule { this.LDAP_USER = config.get('LDAP_USER'); this.LDAP_PWD = config.get('LDAP_PWD'); this.ANNAL_UPLOAD_DIR = config.get('ANNAL_UPLOAD_DIR'); + this.MEDIA_UPLOAD_DIR = config.get('MEDIA_UPLOAD_DIR'); + this.MEDIA_DETACHED_LIFESPAN = Number(config.get('MEDIA_DETACHED_LIFESPAN')); this.IS_PROD_ENV = isProdEnv; this.TIMETABLE_URL = config.get('TIMETABLE_URL'); if (this.ANNAL_UPLOAD_DIR.endsWith('/')) this.ANNAL_UPLOAD_DIR = this.ANNAL_UPLOAD_DIR.slice(0, -1); + if (this.MEDIA_UPLOAD_DIR.endsWith('/')) this.MEDIA_UPLOAD_DIR = this.MEDIA_UPLOAD_DIR.slice(0, -1); this.ETUUTT_WEBSITE_APPLICATION_ID = config.get('ETUUTT_WEBSITE_APPLICATION_ID'); this._FAKER_SEED = isTestEnv ? Number(config.get('FAKER_SEED')) : undefined; diff --git a/src/exceptions.ts b/src/exceptions.ts index 28c17707..f8296ea2 100644 --- a/src/exceptions.ts +++ b/src/exceptions.ts @@ -40,6 +40,7 @@ export const enum ERROR_CODE { BODY_MISSING = 2022, PARAM_PAST_DATE = 2023, PARAM_MISSING_EITHER = 2024, + PARAM_LEXICAL_ILLEGAL = 2101, PARAM_DOES_NOT_MATCH_REGEX = 2102, NO_FIELD_PROVIDED = 2201, WIDGET_OVERLAPPING = 2301, @@ -68,6 +69,8 @@ export const enum ERROR_CODE { NOT_ALREADY_DONE_UEOF = 4229, APPLICATION_NOT_OWNED = 4230, USER_ALREADY_ASSO_ROLE_MEMBER = 4231, + MEDIA_NOT_PUBLIC = 4232, + MEDIA_PRESET_REQUIRED = 4233, NO_SUCH_UE = 4401, NO_SUCH_COMMENT = 4402, NO_SUCH_REPLY = 4403, @@ -84,11 +87,13 @@ export const enum ERROR_CODE { NO_SUCH_UE_AT_SEMESTER = 4414, NO_SUCH_ASSO_ROLE = 4415, NO_SUCH_ASSO_MEMBERSHIP = 4416, + NO_SUCH_MEDIA = 4417, ANNAL_ALREADY_UPLOADED = 4901, RESOURCE_UNAVAILABLE = 4902, RESOURCE_INVALID_TYPE = 4903, ASSO_ROLE_ALREADY_MOVED = 4904, CREDENTIALS_ALREADY_TAKEN = 5001, + SERVER_DISK_ERROR = 8001, HIDDEN_DUCK = 9999, } @@ -206,6 +211,10 @@ export const ErrorData = Object.freeze({ message: 'One of these parameters must be provided: %', httpCode: HttpStatus.BAD_REQUEST, }, + [ERROR_CODE.PARAM_LEXICAL_ILLEGAL]: { + message: 'Content has a wrong syntax: %', + httpCode: HttpStatus.BAD_REQUEST, + }, [ERROR_CODE.PARAM_DOES_NOT_MATCH_REGEX]: { message: 'The following parameters must match the regex "%": %', httpCode: HttpStatus.BAD_REQUEST, @@ -318,6 +327,14 @@ export const ErrorData = Object.freeze({ message: 'User is already member of this role: %', httpCode: HttpStatus.CONFLICT, }, + [ERROR_CODE.MEDIA_NOT_PUBLIC]: { + message: 'Media must be public', + httpCode: HttpStatus.FORBIDDEN, + }, + [ERROR_CODE.MEDIA_PRESET_REQUIRED]: { + message: 'Media must have preset %', + httpCode: HttpStatus.FORBIDDEN, + }, [ERROR_CODE.NO_SUCH_UE]: { message: 'The UE % does not exist', httpCode: HttpStatus.NOT_FOUND, @@ -382,6 +399,10 @@ export const ErrorData = Object.freeze({ message: 'No such membership in asso: %', httpCode: HttpStatus.NOT_FOUND, }, + [ERROR_CODE.NO_SUCH_MEDIA]: { + message: 'No such media: %', + httpCode: HttpStatus.NOT_FOUND, + }, [ERROR_CODE.ANNAL_ALREADY_UPLOADED]: { message: 'A file has alreay been uploaded for this annal', httpCode: HttpStatus.CONFLICT, @@ -402,6 +423,10 @@ export const ErrorData = Object.freeze({ message: 'You should not try to update role position simultaneously', httpCode: HttpStatus.CONFLICT, }, + [ERROR_CODE.SERVER_DISK_ERROR]: { + message: 'An error occurred while accessing the server disk', + httpCode: HttpStatus.SERVICE_UNAVAILABLE, + }, [ERROR_CODE.HIDDEN_DUCK]: { message: 'Hey, you found the hidden duck ! Error : %', httpCode: HttpStatus.I_AM_A_TEAPOT, diff --git a/src/lexical/lexical.module.ts b/src/lexical/lexical.module.ts new file mode 100644 index 00000000..2d36b682 --- /dev/null +++ b/src/lexical/lexical.module.ts @@ -0,0 +1,133 @@ +import { Module } from '@nestjs/common'; +import { TextNode, ParagraphNode } from 'lexical'; +import { createHeadlessEditor } from '@lexical/headless'; +import { $generateHtmlFromNodes } from '@lexical/html'; +import { AutoLinkNode, LinkNode } from '@lexical/link'; +import { HeadingNode, QuoteNode } from '@lexical/rich-text'; +import { CodeHighlightNode, CodeNode } from '@lexical/code'; +import { TableNode, TableCellNode, TableRowNode } from '@lexical/table'; +import { ListNode, ListItemNode } from '@lexical/list'; +import { HorizontalRuleNode } from '@lexical/extension'; +import { ColorTextNode, ImageNode, RegisteredStyleMap } from './nodes'; +import { patchNodeExportDOM } from './nodes/NodeStyleInjector'; + +/** @internal */ +export const BUNDLES = { + '@etuutt/simple': [], + '@etuutt/full': [ + AutoLinkNode, + CodeHighlightNode, + CodeNode, + ColorTextNode, + HeadingNode, + HorizontalRuleNode, + ImageNode, + LinkNode, + ListItemNode, + ListNode, + TableCellNode, + TableNode, + TableRowNode, + QuoteNode, + ], +}; + +@Module({ + exports: [LexicalModule], +}) +export class LexicalModule { + constructor() { + [TextNode, ParagraphNode, ...BUNDLES['@etuutt/full']].forEach(patchNodeExportDOM); + } + + /** + * Validates the user-provided string as valid Lexical content. Checks both structure and content. + * + * Checks that the content only contains nodes from the provided bundle (you can define custom bundles in {@link BUNDLES}). + * This check is performed by parsing the content and re-serializing it, then comparing the result to the original input. + * This forbids the use of unknown nodes as well as unknown properties. However, the client implementation is supposed to + * serialize nodes the same way as the API does so that properties are in the same order. + * + * @param userInput the string provided by the user + * @param bundle the bundle of allowed nodes (default: full bundle) + * @returns true if the content is valid, false otherwise + */ + isValidLexicalContent(userInput: string, bundle: keyof typeof BUNDLES = '@etuutt/full') { + try { + const editor = createHeadlessEditor({ + nodes: BUNDLES[bundle], + onError: () => {}, + }); + const parsed = JSON.parse(userInput); + const editorState = editor.parseEditorState(parsed); + return JSON.stringify(editorState.toJSON()) === userInput; + } catch { + return false; + } + } + + /** + * Generates HTML from lexical content. Nodes not in included the bundle are ignored. The output is sanitized (by happy-dom) and + * contains inline-styles instead of classes, for email use. Inline style is defined in the {@link CustomStyles} (./nodes/index.ts). + * + * This function can not be used in a jest context as it relies on happy-dom to provide a DOM implementation. + * + * @param lexicalContent the lexical content to convert + * @param bundle the bundle of allowed nodes (default: full bundle) + * @returns the generated HTML + */ + async generateHTML(lexicalContent: string, bundle: keyof typeof BUNDLES = '@etuutt/full'): Promise { + let html = ''; + const { withDOM } = (await new Function( + "return import('@lexical/headless/dom')", + )()) as typeof import('@lexical/headless/dom'); + withDOM(() => { + const editor = createHeadlessEditor({ + nodes: BUNDLES[bundle], + theme: { + image: 'editor-image', + link: 'editor-link', + text: { + bold: 'editor-bold', + italic: 'editor-italic', + underline: 'editor-underline', + strikethrough: 'editor-strikethrough', + code: 'editor-code', + }, + paragraph: 'editor-no-margin', + heading: { + h1: 'editor-h1', + h2: 'editor-h2', + h3: 'editor-no-margin', + h4: 'editor-no-margin', + h5: 'editor-no-margin', + h6: 'editor-no-margin', + }, + quote: 'editor-quote', + hr: 'editor-horizontal-rule', + list: { + checklist: 'editor-checklist', + listitem: 'editor-list-item', + listitemChecked: 'editor-list-item-checked', + listitemUnchecked: 'editor-list-item-unchecked', + ol: 'editor-ordered-list', + ul: 'editor-unordered-list', + nested: { + listitem: 'editor-list-item-nested', + }, + }, + table: 'editor-table', + tableCell: 'editor-table-cell', + tableCellHeader: 'editor-table-cell-header', + tableRow: 'editor-table-row', + } satisfies RegisteredStyleMap, + }); + const parsed = JSON.parse(lexicalContent); + editor.setEditorState(editor.parseEditorState(parsed)); + editor.read(() => (html = $generateHtmlFromNodes(editor))); + }); + return html + .replaceAll('class=""', '') + .replaceAll(/(?<=<[^>]+)(?]*>)|(?<=<[^>]*(?:\w|"))\s+(?=>)/g, ''); + } +} diff --git a/src/lexical/nodes/ColorTextNode.ts b/src/lexical/nodes/ColorTextNode.ts new file mode 100644 index 00000000..422c30f8 --- /dev/null +++ b/src/lexical/nodes/ColorTextNode.ts @@ -0,0 +1,63 @@ +import { + $getState, + $setState, + createState, + DOMConversionMap, + DOMExportOutput, + LexicalEditor, + NodeKey, + SerializedTextNode, + Spread, + TextNode, +} from 'lexical'; + +const ColorOptions = { blue: '#2d8fce', darkblue: '#1b557a', grey: '#444c5f', darkgrey: '#2e3442' }; +export type ColorType = keyof typeof ColorOptions; + +type SerializedColorTextNode = Spread<{ color?: ColorType }, SerializedTextNode>; + +const colorState = createState('color', { + parse: (v) => ((v as ColorType) in ColorOptions ? (v as ColorType) : undefined), +}); + +export class ColorTextNode extends TextNode { + static getType() { + return 'color-text'; + } + + static clone(node: ColorTextNode) { + return new ColorTextNode(node.__text, node.__key); + } + + setColor(color?: ColorType) { + $setState(this, colorState, color); + return this; + } + + static importJSON(serializedNode: SerializedColorTextNode): ColorTextNode { + return $createColorTextNode(serializedNode.text).updateFromJSON(serializedNode).setColor(serializedNode.color); + } + + exportJSON(): SerializedColorTextNode { + return { + ...super.exportJSON(), + color: $getState(this, colorState), + $: undefined, + }; + } + + exportDOM(editor: LexicalEditor): DOMExportOutput { + const { element } = super.exportDOM(editor); + const color = $getState(this, colorState); + if (color) (element as HTMLElement).style.color = ColorOptions[color]; + return { element }; + } + + static importDOM(): DOMConversionMap { + return null; + } +} + +export function $createColorTextNode(text?: string, nodeKey?: NodeKey): ColorTextNode { + return new ColorTextNode(text, nodeKey); +} diff --git a/src/lexical/nodes/ImageNode.ts b/src/lexical/nodes/ImageNode.ts new file mode 100644 index 00000000..966aca5f --- /dev/null +++ b/src/lexical/nodes/ImageNode.ts @@ -0,0 +1,87 @@ +import { + DecoratorNode, + DOMConversionMap, + DOMExportOutput, + LexicalEditor, + NodeKey, + SerializedLexicalNode, + Spread, +} from 'lexical'; + +type SerializedImageNode = Spread< + { + src: string; + altText: string; + width: number | 'inherit'; + height: number | 'inherit'; + }, + SerializedLexicalNode +>; + +export class ImageNode extends DecoratorNode { + __src: string; + __altText: string; + __width: number | 'inherit'; + __height: number | 'inherit'; + + static getType() { + return 'image'; + } + + static clone(node: ImageNode) { + return new ImageNode(node.__src, node.__altText, node.__width, node.__height, node.__key); + } + + constructor(src: string, altText?: string, width?: number | 'inherit', height?: number | 'inherit', key?: NodeKey) { + super(key); + this.__src = src; + this.__altText = altText || ''; + this.__width = width || 'inherit'; + this.__height = height || 'inherit'; + } + + exportDOM(editor: LexicalEditor): DOMExportOutput { + const element = document.createElement('span'); + if (editor._config?.theme?.image) element.className = editor._config.theme.image; + const img = document.createElement('img'); + img.src = this.__src; + img.alt = this.__altText; + if (this.__width !== 'inherit') img.width = this.__width; + if (this.__height !== 'inherit') img.height = this.__height; + element.appendChild(img); + return { element }; + } + + static importJSON(serializedNode: SerializedImageNode): ImageNode { + return $createImageNode( + serializedNode.src, + serializedNode.altText, + serializedNode.width, + serializedNode.height, + ).updateFromJSON(serializedNode); + } + + exportJSON(): SerializedImageNode { + return { + ...super.exportJSON(), + src: this.__src, + altText: this.__altText, + width: this.__width, + height: this.__height, + }; + } + + static importDOM(): DOMConversionMap { + return null; + } +} + +export function $createImageNode( + src: string, + altText?: string, + width?: number | 'inherit', + height?: number | 'inherit', + nodeKey?: NodeKey, +): ImageNode { + return new ImageNode(src, altText, width, height, nodeKey); +} diff --git a/src/lexical/nodes/NodeStyleInjector.ts b/src/lexical/nodes/NodeStyleInjector.ts new file mode 100644 index 00000000..cc257900 --- /dev/null +++ b/src/lexical/nodes/NodeStyleInjector.ts @@ -0,0 +1,61 @@ +import { CodeHighlightNode } from '@lexical/code'; +import { TableCellNode } from '@lexical/table'; +import { LexicalNode } from 'lexical'; +import { CustomStyles } from '.'; + +/** Applies style to a specific Node */ +function applyStylesToElement(element: HTMLElement, index: number, node?: LexicalNode) { + element.classList.forEach((cName) => { + if (!(cName in CustomStyles)) return; + if (cName === 'editor-list-item-checked' || cName === 'editor-list-item-unchecked') + Object.assign(element.style, CustomStyles['editor-list-item-check-base']); + else if (cName !== 'editor-table-row' || !(index % 2)) + Object.assign(cName === 'editor-image' ? element.querySelector('img').style : element.style, CustomStyles[cName]); + if (cName === 'editor-table-cell-header' && node instanceof TableCellNode) + element.style.textAlign = node.__verticalAlign; + if (cName === 'editor-list-item-checked' || cName === 'editor-list-item-unchecked') { + const prependedElement = document.createElement('div'); + Object.assign(prependedElement.style, CustomStyles[cName]); + if (cName === 'editor-list-item-checked') { + const iconElement = document.createElement('div'); + Object.assign(iconElement.style, CustomStyles['editor-list-item-checked-icon']); + prependedElement.prepend(iconElement); + } + element.prepend(prependedElement); + } + }); + if (!element.className.includes('editor-ordered-list') && !element.className.includes('editor-unordered-list')) + element.className = ''; +} + +/** + * Patches the exportDOM method of a Lexical Node to inject inline styles based on class names. + */ +export function patchNodeExportDOM( + NodeClass: new (...args: unknown[]) => T extends (infer U)[] ? U : never, +) { + const original = NodeClass.prototype.exportDOM; + + NodeClass.prototype.exportDOM = function (this: LexicalNode, ...args: unknown[]) { + const result = original.apply(this, args); + const originalAfterFunction = result?.after; + result.after = (element: HTMLElement | null) => { + let updatedElement = element; + if (originalAfterFunction) updatedElement = originalAfterFunction(element); + applyStylesToElement(updatedElement, this.getIndexWithinParent(), this); + updatedElement + .querySelectorAll('[class]') + .forEach((element: HTMLElement) => + applyStylesToElement(element, Array.prototype.indexOf.call(element.parentElement.children, element)), + ); + }; + + return result; + }; + // Remove warning of having no importDOM method + if ((NodeClass as unknown) === CodeHighlightNode) { + Object.defineProperty(NodeClass, 'importDOM', { + value: () => null, + }); + } +} diff --git a/src/lexical/nodes/index.ts b/src/lexical/nodes/index.ts new file mode 100644 index 00000000..48492699 --- /dev/null +++ b/src/lexical/nodes/index.ts @@ -0,0 +1,151 @@ +import type { EditorThemeClasses } from 'lexical'; +export { ColorTextNode } from './ColorTextNode'; +export { ImageNode } from './ImageNode'; +import './NodeStyleInjector'; + +export type RegisteredStyleMap = { + [K1 in keyof EditorThemeClasses]: EditorThemeClasses[K1] extends Record + ? { + [K2 in keyof EditorThemeClasses[K1]]: EditorThemeClasses[K1][K2] extends Record + ? { [K3 in keyof EditorThemeClasses[K1][K2]]: keyof typeof CustomStyles } + : keyof typeof CustomStyles; + } + : keyof typeof CustomStyles; +}; + +/** + * Style to apply to elements of lexical content during HTML export. + * Every key represents a class name that can be applied to lexical nodes. + */ +export const CustomStyles = { + 'editor-image': { + maxWidth: '100%', + maxHeight: '100%', + height: 'auto', + borderRadius: '3px', + backgroundColor: 'rgba(68, 76, 95, 0.5)', + }, + 'editor-link': { + color: '#2d8fce', + borderBottom: '1px solid #2d8fce', + textDecoration: 'none', + }, + 'editor-bold': { + fontWeight: 'bold', + }, + 'editor-italic': { + fontStyle: 'italic', + }, + 'editor-underline': { + textDecoration: 'underline', + }, + 'editor-strikethrough': { + textDecoration: 'line-through', + }, + 'editor-code': { + backgroundColor: 'rgba(46, 52, 66, 0.1)', + borderRadius: '3px', + }, + 'editor-quote': { + backgroundColor: 'rgba(46, 52, 66, 0.1)', + margin: '0.5em', + padding: '0.5em', + borderLeft: '5px solid #2e3442', + borderRadius: '0 5px 5px 0', + }, + 'editor-horizontal-rule': { + border: 'none', + borderTop: '2px solid #2e3442', + margin: '1em 0', + }, + 'editor-no-margin': { + margin: '0', + }, + 'editor-h1': { + textTransform: 'uppercase', + fontWeight: '900', + margin: '0', + }, + 'editor-h2': { + fontWeight: 'bold', + margin: '0', + }, + 'editor-checklist': { + marginLeft: '-1.5em', + }, + 'editor-list-item': { + position: 'relative', + }, + 'editor-list-item-nested': { + listStyleType: 'none', + }, + 'editor-list-item-check-base': { + position: 'relative', + marginLeft: '0.5em', + marginRight: '0.5em', + paddingLeft: '1.5em', + paddingRight: '1.5em', + outline: 'none', + display: 'block', + }, + 'editor-list-item-checked': { + width: '0.9em', + height: '0.9em', + top: '50%', + left: '0', + display: 'block', + backgroundSize: 'cover', + position: 'absolute', + transform: 'translateY(-50%)', + border: '1px solid #2d8fce', + borderRadius: '2px', + backgroundColor: '#2d8fce', + backgroundRepeat: 'no-repeat', + }, + 'editor-list-item-checked-icon': { + borderColor: '#fafbfc', + borderStyle: 'solid', + position: 'absolute', + display: 'block', + top: '45%', + width: '0.2em', + left: '0.32em', + height: '0.4em', + transform: 'translateY(-50%) rotate(45deg)', + borderWidth: '0 0.1em 0.1em 0', + }, + 'editor-list-item-unchecked': { + width: '0.9em', + height: '0.9em', + top: '50%', + left: '0', + display: 'block', + backgroundSize: 'cover', + position: 'absolute', + transform: 'translateY(-50%)', + border: '1px solid rgba(68, 76, 95, 0.5)', + borderRadius: '2px', + }, + 'editor-ordered-list': { + paddingTop: '0', + }, + 'editor-unordered-list': { + paddingTop: '0', + }, + 'editor-table': { + borderCollapse: 'collapse', + }, + 'editor-table-cell': { + padding: '3px 4px', + minWidth: '200px', + border: '1px solid rgba(68, 76, 95, 0.3)', + }, + 'editor-table-cell-header': { + backgroundColor: '#2e3442', + color: '#fafbfc', + border: '1px solid #2e3442', + }, + 'editor-table-row': { + backgroundColor: 'rgba(68, 76, 95, 0.1)', + }, +} satisfies Record>; diff --git a/src/media/image/dto/req/imagemedia-upload-req.dto.ts b/src/media/image/dto/req/imagemedia-upload-req.dto.ts new file mode 100644 index 00000000..ed1d6e8e --- /dev/null +++ b/src/media/image/dto/req/imagemedia-upload-req.dto.ts @@ -0,0 +1,51 @@ +import { ImageMediaPreset } from '@prisma/client'; +import { Type } from 'class-transformer'; +import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export default class ImageMediaUploadReqDto { + @IsOptional() + @IsInt() + @Min(100) + @Max(1920) + @Type(() => Number) + width?: number; + + @IsOptional() + @IsInt() + @Min(100) + @Max(1080) + @Type(() => Number) + height?: number; + + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + @Type(() => Number) + quality?: number; + + @IsOptional() + @IsInt() + @Min(0) + @Max(6) + @Type(() => Number) + effort?: number; + + @IsOptional() + @IsInt() + @Min(0) + @Max(3) + @Type(() => Number) + rotation?: 0 | 1 | 2 | 3; + + @IsOptional() + @IsString() + @IsEnum(ImageMediaPreset) + preset?: ImageMediaPreset; + + /** By default, images are NOT public and only accessible to logged users. */ + @IsOptional() + @IsBoolean() + @Type(() => Boolean) + public?: boolean; +} diff --git a/src/media/image/dto/res/imagemedia-upload-res.dto.ts b/src/media/image/dto/res/imagemedia-upload-res.dto.ts new file mode 100644 index 00000000..05765717 --- /dev/null +++ b/src/media/image/dto/res/imagemedia-upload-res.dto.ts @@ -0,0 +1,10 @@ +import { ImageMediaPreset } from '@prisma/client'; + +export default class ImageMediaUploadResDto { + id: string; + width: number; + height: number; + size: number; + isPublic: boolean; + preset: ImageMediaPreset; +} diff --git a/src/media/image/imagemedia.controller.ts b/src/media/image/imagemedia.controller.ts new file mode 100644 index 00000000..6141ed9f --- /dev/null +++ b/src/media/image/imagemedia.controller.ts @@ -0,0 +1,69 @@ +import { Controller, Get, Post, Query, Response } from '@nestjs/common'; +import { ApiConsumes, ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Response as ExpressResponse } from 'express'; +import { FileSize, MulterWithMime, UploadRoute, UserFile } from '../../upload.interceptor'; +import { GetUser, IsPublic, RequireApiPermission, SkipApplicationCheck } from '../../auth/decorator'; +import { AppException, ERROR_CODE } from '../../exceptions'; +import { ApiAppErrorResponse } from '../../app.dto'; +import { ImageMediaService } from './imagemedia.service'; +import { UUIDParam } from '../../app.pipe'; +import { omit } from '../../utils'; +import { User } from '../../users/interfaces/user.interface'; +import ImageMediaUploadReqDto from './dto/req/imagemedia-upload-req.dto'; +import ImageMediaUploadResDto from './dto/res/imagemedia-upload-res.dto'; + +@Controller('media/image') +@ApiTags('Media') +export class ImageMediaController { + constructor(readonly imageMediaService: ImageMediaService) {} + + @Get('/:mediaId.webp') + @SkipApplicationCheck() + @IsPublic() + @ApiOperation({ description: 'Retrieve a media by its id.' }) + @ApiOkResponse({ description: 'The media is contained in the body of the response' }) + @ApiAppErrorResponse(ERROR_CODE.NO_SUCH_MEDIA, 'There is no media with the given id') + @ApiAppErrorResponse(ERROR_CODE.NOT_LOGGED_IN, 'This media is not public and requires authentication') + @ApiAppErrorResponse(ERROR_CODE.SERVER_DISK_ERROR, 'An error occurred while reading from disk') + async getMedia(@UUIDParam('mediaId') mediaId: string, @GetUser() user: User, @Response() response: ExpressResponse) { + const media = await this.imageMediaService.getMedia(mediaId); + if (!media) throw new AppException(ERROR_CODE.NO_SUCH_MEDIA, mediaId); + if (!media.isPublic && !user) throw new AppException(ERROR_CODE.NOT_LOGGED_IN); + const stream = this.imageMediaService.readMediaFromDisk(mediaId); + response.setHeader('Content-Type', 'image/webp'); + response.setHeader('Cache-Control', `${media.isPublic ? 'public' : 'private'}, max-age=31536000, immutable`); // 1 year + stream.pipe(response); + stream.on('error', () => { + stream.close(); + const exception = new AppException(ERROR_CODE.SERVER_DISK_ERROR); + response.status(exception.getStatus()).json(exception.getResponse()); + }); + } + + @Post('/') + @RequireApiPermission('API_UPLOAD_MEDIA') + @UploadRoute('file') + @ApiConsumes('image/png', 'image/jpeg', 'image/webp', 'image/avif', 'image/tiff') + @ApiOperation({ description: 'Uploads a media and returns its id.' }) + @ApiCreatedResponse({ type: ImageMediaUploadResDto }) + @ApiAppErrorResponse(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'The permission is missing on the API key') + @ApiAppErrorResponse(ERROR_CODE.SERVER_DISK_ERROR, 'An error occurred while writing to disk') + async uploadMedia( + @UserFile(['image/png', 'image/jpeg', 'image/webp', 'image/avif', 'image/tiff'], 8 * FileSize.MegaByte) + file: Promise, + @GetUser() user: User, + @Query() options: ImageMediaUploadReqDto, + ): Promise { + const multer = await file; + const media = await this.imageMediaService.convertMedia(multer, options); + const savedMedia = await this.imageMediaService.registerMedia(media, user, options.public ?? false); + try { + await this.imageMediaService.writeMediaToDisk(savedMedia.id, multer.multer.buffer); + } catch { + await this.imageMediaService.unRegisterMedia(savedMedia.id); + throw new AppException(ERROR_CODE.SERVER_DISK_ERROR); + } + this.imageMediaService.cleanup(); + return omit(savedMedia, 'uploaderId', 'uploadedAt'); + } +} diff --git a/src/media/image/imagemedia.module.ts b/src/media/image/imagemedia.module.ts new file mode 100644 index 00000000..a3978240 --- /dev/null +++ b/src/media/image/imagemedia.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ImageMediaController } from './imagemedia.controller'; +import { ImageMediaService } from './imagemedia.service'; + +@Module({ + controllers: [ImageMediaController], + providers: [ImageMediaService], + exports: [ImageMediaService], +}) +export class ImageMediaModule {} diff --git a/src/media/image/imagemedia.service.ts b/src/media/image/imagemedia.service.ts new file mode 100644 index 00000000..f5b714ef --- /dev/null +++ b/src/media/image/imagemedia.service.ts @@ -0,0 +1,121 @@ +import { createReadStream, ReadStream } from 'fs'; +import { rm, writeFile } from 'fs/promises'; +import { Injectable } from '@nestjs/common'; +import { ImageMedia, ImageMediaPreset } from '@prisma/client'; +import { ConfigModule } from '../../config/config.module'; +import { PrismaService } from '../../prisma/prisma.service'; +import { MulterWithMime } from '../../upload.interceptor'; +import { User } from '../../users/interfaces/user.interface'; +import ImageMediaUploadReqDto from './dto/req/imagemedia-upload-req.dto'; +import sharp from 'sharp'; + +export type ConversionOptions = Omit; + +type PresetStruct = Record< + Exclude, + Omit +>; +const presets: PresetStruct = { + AVATAR: { width: 256, height: 256, quality: 70, effort: 5 }, +}; + +export type ImageMetadata = Omit; + +@Injectable() +export class ImageMediaService { + constructor( + readonly prisma: PrismaService, + readonly config: ConfigModule, + ) {} + + async convertMedia(file: MulterWithMime, options: ConversionOptions): Promise { + if (!(options.preset in presets)) options.preset = ImageMediaPreset.CUSTOM; + if (options.preset) Object.assign(options, presets[options.preset]); + let instructions = sharp(file.multer.buffer); + let metadata = await instructions.metadata(); + const size = [metadata.width, metadata.height]; + if (options.rotation) { + instructions = instructions.rotate(options.rotation * 90); + size.reverse(); + } + if (size[0] > 1920 && !options.width) options.width = 1920; + if (size[1] > 1080 && !options.height) options.height = 1080; + if (options.width && options.height) + instructions = instructions.resize(options.width, options.height, { fit: 'cover' }); + file.mime = 'image/webp'; + instructions = instructions.webp({ + quality: options.quality, + effort: options.effort, + nearLossless: true, + smartSubsample: true, + alphaQuality: options.quality, + }); + file.multer.buffer = await instructions.toBuffer(); + metadata = await sharp(file.multer.buffer).metadata(); + return { width: metadata.width, height: metadata.height, size: file.multer.buffer.length, preset: options.preset }; + } + + async registerMedia(metaData: ImageMetadata, uploader: User, isPublic: boolean): Promise { + const image = await this.prisma.imageMedia.create({ + data: { + ...metaData, + uploader: { + connect: { id: uploader.id }, + }, + isPublic, + }, + }); + return image; + } + + async rollbackMedia(media: ImageMedia): Promise { + await this.prisma.imageMedia.create({ data: media }); + } + + async unRegisterMedia(mediaId: string): Promise { + return this.prisma.imageMedia.delete({ where: { id: mediaId } }); + } + + async getMedia(mediaId: string) { + return this.prisma.imageMedia.findUnique({ where: { id: mediaId } }); + } + + /** + * Clears unused from the Database. {@link ImageMediaService.deleteMediaFromDisk DeleteMediaFromDisk} must be called + * with the output of this method to clear data from disk. + */ + async clearUnusedMedia(): Promise { + const targetMedias = await this.prisma.imageMedia.findMany({ + where: { + // Filter explanation https://www.prisma.io/docs/orm/prisma-client/queries/relation-queries#filter-on-absence-of--to-many-records + avatarForUsers: { none: {} }, + logoForAssos: { none: {} }, + descriptionForAssos: { none: {} }, + uploadedAt: { lt: new Date(Date.now() - this.config.MEDIA_DETACHED_LIFESPAN * 3_600_000) }, + }, + }); + await this.prisma.imageMedia.deleteMany({ + where: { id: { in: targetMedias.map((media) => media.id) } }, + }); + return targetMedias; + } + + async writeMediaToDisk(mediaId: string, buffer: Buffer): Promise { + await writeFile(`${this.config.MEDIA_UPLOAD_DIR}/image/${mediaId}.webp`, buffer); + } + + readMediaFromDisk(mediaId: string): ReadStream { + return createReadStream(`${this.config.MEDIA_UPLOAD_DIR}/image/${mediaId}.webp`); + } + + async deleteMediaFromDisk(mediaId: string): Promise { + await rm(`${this.config.MEDIA_UPLOAD_DIR}/image/${mediaId}.webp`, { force: true }); + } + + async cleanup() { + const media = await this.clearUnusedMedia(); + (await Promise.all(media.map((m) => this.deleteMediaFromDisk(m.id).catch(() => m)))).map( + (r) => r && this.rollbackMedia(r), + ); + } +} diff --git a/src/prisma/types.ts b/src/prisma/types.ts index c29ba04c..b52bd894 100644 --- a/src/prisma/types.ts +++ b/src/prisma/types.ts @@ -37,6 +37,7 @@ export { UserPrivacy as RawUserPrivacy, ApiApplication as RawApiApplication, ApiKey as RawApiKey, + ImageMedia as RawImageMedia, } from '@prisma/client'; export { RawTranslation }; diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 28062760..ca890110 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -112,7 +112,8 @@ export default class UsersController { studentId: user.studentId, userType: user.userType, infos: { - ...pick(user.infos, 'nickname', 'avatar', 'nationality', 'passions', 'website'), + ...pick(user.infos, 'nickname', 'nationality', 'passions', 'website'), + avatar: user.infos.avatar ? `/media/image/${user.infos.avatar.id}.webp` : null, sex: user.privacy.sex || includeAll ? user.infos.sex : undefined, birthday: user.privacy.birthday || includeAll ? user.infos.birthday : undefined, }, @@ -153,7 +154,7 @@ export default class UsersController { lastName: user.lastName, nickname: user.infos.nickname, type: user.userType, - avatar: user.infos.avatar, + avatar: user.infos.avatar ? `/media/image/${user.infos.avatar.id}.webp` : null, sex: user.privacy.sex || includeAll ? user.infos.sex : undefined, nationality: user.infos.nationality, birthday: user.privacy.birthday || includeAll ? user.infos.birthday : undefined, diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 8c039e40..c7489920 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -124,7 +124,14 @@ export default class UsersService { }, }, }) - ).map((membership) => ({ ...omit(membership, 'role'), role: membership.role.name })); + ).map((membership) => ({ + ...omit(membership, 'role'), + role: membership.role.name, + asso: { + ...membership.asso, + logo: membership.asso.logo ? `/media/image/${membership.asso.logo.id}.webp` : null, + }, + })); return membership; } @@ -135,7 +142,7 @@ export default class UsersService { infos: { update: { nickname: dto.nickname, - avatar: dto.avatar, + avatar: { connect: dto.avatar ? { id: dto.avatar } : undefined }, passions: dto.passions, website: dto.website, }, diff --git a/src/utils.ts b/src/utils.ts index 8e468be3..f8f43d07 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,4 @@ +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { Language, Permission } from '@prisma/client'; import { Translation } from './prisma/types'; import { ApiPermission, UserPermission } from './auth/interfaces/permissions.interface'; @@ -57,6 +58,29 @@ export const translationSelect = { }, }; +export class TranslatedTextDto { + @IsOptional() + @IsString() + @IsNotEmpty() + fr?: string; + @IsOptional() + @IsString() + @IsNotEmpty() + en?: string; + @IsOptional() + @IsString() + @IsNotEmpty() + de?: string; + @IsOptional() + @IsString() + @IsNotEmpty() + es?: string; + @IsOptional() + @IsString() + @IsNotEmpty() + zh?: string; +} + export class PermissionManager { public readonly hardPermissions: Permission[]; public readonly softPermissions: { diff --git a/test/declarations.d.ts b/test/declarations.d.ts index bf412f0a..b585d0d5 100644 --- a/test/declarations.d.ts +++ b/test/declarations.d.ts @@ -7,6 +7,7 @@ import { FakeAssoMembership, FakeAssoMembershipPermission, FakeAssoMembershipRole, + FakeImageMedia, FakeUeAnnalType, FakeUeof, } from './utils/fakedb'; @@ -21,9 +22,11 @@ import { PermissionManager } from '../src/utils'; type JsonLikeVariant = Partial<{ [K in keyof T]: T[K] extends string | Date ? symbol | RegExp | T[K] - : T[K] extends (infer R)[] - ? JsonLikeVariant[] - : JsonLikeVariant; + : T[K] extends number + ? symbol | number + : T[K] extends (infer R)[] + ? JsonLikeVariant[] + : JsonLikeVariant; }>; type FakeUeWithOfs = FakeUe & { ueofs: FakeUeof[] }; @@ -104,6 +107,7 @@ declare module './declarations' { expectCreditCategories(categories: JsonLikeVariant): this; expectApplications(applications: FakeApiApplication[]): this; expectApplication(application: FakeApiApplication): this; + expectImageMedia(media: JsonLikeVariant): this; expectPermissions(permissions: PermissionManager): this; diff --git a/test/declarations.ts b/test/declarations.ts index 1d8e1f5f..93894d4e 100644 --- a/test/declarations.ts +++ b/test/declarations.ts @@ -15,12 +15,13 @@ import { FakeApiApplication, FakeAssoMembershipRole, FakeAssoMembership, + FakeImageMedia, } from './utils/fakedb'; import { UeAnnalFile } from 'src/ue/annals/interfaces/annal.interface'; import { ConfigModule } from '../src/config/config.module'; import { AppProvider, JsonLike } from './utils/test_utils'; import { getTranslation, omit, PermissionManager, pick } from '../src/utils'; -import { regex, string, uuid } from 'pactum-matchers'; +import { regex, string, uuid, int } from 'pactum-matchers'; import { Language } from '@prisma/client'; import { DEFAULT_APPLICATION } from '../prisma/seed/utils'; import ApplicationResDto from '../src/auth/application/dto/res/application-res.dto'; @@ -133,7 +134,10 @@ Spec.prototype.expectUsers = function (app: AppProvider, users: FakeUser[], coun return (this).expectStatus(HttpStatus.OK).$expectRegexableJson({ items: users.map((user) => ({ ...pick(user, 'id', 'firstName', 'lastName', 'login', 'studentId', 'userType'), - infos: pick(user.infos, 'nickname', 'avatar', 'nationality', 'passions', 'website'), + infos: { + ...pick(user.infos, 'nickname', 'nationality', 'passions', 'website'), + avatar: user.infos.avatarMediaId ? `/media/image/${user.infos.avatarMediaId}.webp` : null, + }, branchSubscriptions: user.branchSubscriptions.map((branch) => pick(branch, 'id')), mailsPhones: pick(user.mailsPhones, 'mailUTT'), socialNetwork: omit(user.socialNetwork, 'id', 'discord'), @@ -223,7 +227,8 @@ Spec.prototype.expectHomepageWidgets = function (this: Spec, widgets: Omit ({ - ...pick(asso, 'id', 'name', 'logo'), + ...pick(asso, 'id', 'name'), + logo: asso.logoMediaId ? `/media/image/${asso.logoMediaId}.webp` : null, shortDescription: getTranslation(asso.descriptionShortTranslation, (this).language), president: { role: !!asso.presidentRole ? pick(asso.presidentRole, 'id', 'name') : null, @@ -236,7 +241,8 @@ Spec.prototype.expectAssos = function (this: Spec, app: AppProvider, assos: Fake }; Spec.prototype.expectAsso = function (asso: FakeAsso) { return (this).expectStatus(HttpStatus.OK).expectJson({ - ...pick(asso, 'id', 'name', 'mail', 'phoneNumber', 'website', 'logo'), + ...pick(asso, 'id', 'name', 'mail', 'phoneNumber', 'website'), + logo: asso.logoMediaId ? `/media/image/${asso.logoMediaId}.webp` : null, description: getTranslation(asso.descriptionTranslation, (this).language), president: { role: !!asso.presidentRole ? pick(asso.presidentRole, 'id', 'name') : null, @@ -315,6 +321,16 @@ Spec.prototype.expectPermissions = function (permissions: PermissionManager) { .mappedSort((permission) => permission.permission), } satisfies PermissionsResDto); }; +Spec.prototype.expectImageMedia = function (media: JsonLikeVariant) { + return (this).expectStatus(HttpStatus.CREATED).$expectRegexableJson({ + id: media.id, + size: media.size, + width: media.width, + height: media.height, + preset: media.preset, + isPublic: media.isPublic, + }); +}; export { Spec, JsonLikeVariant, FakeUeWithOfs }; @@ -330,6 +346,8 @@ Spec.prototype.$expectRegexableJson = function (this: Spec, obj: JsonLikeVari return string(); case JsonLike.UUID: return uuid(); + case JsonLike.INT: + return int(); } } if (Array.isArray(obj)) return obj.map(wrap); @@ -362,6 +380,7 @@ function generateSchema(obj: JsonLikeVariant): object { ), }; if (obj === JsonLike.STRING || obj === JsonLike.UUID) return { type: 'string' }; + if (obj === JsonLike.INT) return { type: 'number' }; switch (typeof obj) { case 'string': return { type: 'string' }; diff --git a/test/e2e/app.e2e-spec.ts b/test/e2e/app.e2e-spec.ts index 4a16e08f..ffb1a38d 100644 --- a/test/e2e/app.e2e-spec.ts +++ b/test/e2e/app.e2e-spec.ts @@ -15,6 +15,7 @@ import * as cas from '../external_services/cas'; import * as timetableProvider from '../external_services/timetable'; import { ConfigModule } from '../../src/config/config.module'; import AssoE2ESpec from './assos'; +import MediaE2ESpec from './media'; describe('EtuUTT API e2e testing', () => { let app: INestApplication; @@ -54,4 +55,5 @@ describe('EtuUTT API e2e testing', () => { TimetableE2ESpec(() => app); // Deactivated, see function UeE2ESpec(() => app); AssoE2ESpec(() => app); + MediaE2ESpec(() => app); }); diff --git a/test/e2e/assos/index.ts b/test/e2e/assos/index.ts index 5ce31380..4948959c 100644 --- a/test/e2e/assos/index.ts +++ b/test/e2e/assos/index.ts @@ -1,6 +1,7 @@ import { INestApplication } from '@nestjs/common'; import SearchE2ESpec from './search.e2e-spec'; import GetAssoE2ESpec from './get-asso.e2e-spec'; +import UpdateAssoE2ESpec from './update-asso.e2e-spec'; import GetAssoMembersE2ESpec from './list-members.e2e-spec'; import AddAssoMemberE2ESpec from './add-member.e2e-spec'; import KickAssoMemberE2ESpec from './kick-member.e2e-spec'; @@ -13,6 +14,7 @@ export default function AssoE2ESpec(app: () => INestApplication) { describe('Assos', () => { SearchE2ESpec(app); GetAssoE2ESpec(app); + UpdateAssoE2ESpec(app); GetAssoMembersE2ESpec(app); AddAssoMemberE2ESpec(app); KickAssoMemberE2ESpec(app); diff --git a/test/e2e/assos/update-asso.e2e-spec.ts b/test/e2e/assos/update-asso.e2e-spec.ts new file mode 100644 index 00000000..1ac05b3a --- /dev/null +++ b/test/e2e/assos/update-asso.e2e-spec.ts @@ -0,0 +1,170 @@ +import { Dummies, e2eSuite } from '../../utils/test_utils'; +import { + createAsso, + createAssoMembership, + createAssoMembershipPermission, + createAssoMembershipRole, + createImageMedia, + createUser, +} from '../../utils/fakedb'; +import * as pactum from 'pactum'; +import { ERROR_CODE } from '../../../src/exceptions'; +import { PrismaService } from '../../../src/prisma/prisma.service'; +import { pick } from '../../../src/utils'; + +const UpdateAssoE2ESpec = e2eSuite('PATCH /assos/:id', (app) => { + const userNotAllowed = createUser(app); + const userAllowed = createUser(app); + const asso = createAsso(app); + const assoMembershipRoleInAsso = createAssoMembershipRole(app, { asso }); + const manageInfosPermission = createAssoMembershipPermission(app, { id: 'manage_infos' }); + createAssoMembership(app, { + asso, + role: assoMembershipRoleInAsso, + user: userAllowed, + permissions: [manageInfosPermission], + }); + const nonPublicMedia = createImageMedia(app, { isPublic: false, preset: 'AVATAR' }); + const nonAvatarMedia = createImageMedia(app, { isPublic: true, preset: 'CUSTOM' }); + const publicMedia = createImageMedia(app, { isPublic: true, preset: 'AVATAR' }); + + const lexicalText = `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"I want you as a ","type":"color-text","version":1},{"detail":0,"format":1,"mode":"normal","style":"","text":"lexical","type":"color-text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" text that passes the ","type":"color-text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":"tests","type":"color-text","version":1,"color":"blue"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"color-text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`; + const nonLexicalText = 'I want you as a non-lexical text that does not pass the tests.'; + const validBody = { + name: 'New name', + email: 'test@utt.fr', + phoneNumber: '+33325000000', + website: 'https://www.utt.fr', + description: { fr: lexicalText }, + descriptionShort: { fr: 'some description' }, + }; + + it('should return 403 as user is not authenticated', () => + pactum.spec().patch(`/assos/${asso.id}`).withBody(validBody).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + + it('should return a 400 as the asso id param is not valid', () => + pactum + .spec() + .withBearerToken(userNotAllowed.token) + .patch(`/assos/thisisnotavaliduuid`) + .withBody(validBody) + .expectAppError(ERROR_CODE.PARAM_NOT_UUID, 'assoId')); + + it('should return a 404 as asso is not found', () => + pactum + .spec() + .withBearerToken(userNotAllowed.token) + .patch(`/assos/${Dummies.UUID}`) + .withBody(validBody) + .expectAppError(ERROR_CODE.NO_SUCH_ASSO, Dummies.UUID)); + + it('should return a 403 as user has no permission', () => + pactum + .spec() + .withBearerToken(userNotAllowed.token) + .patch(`/assos/${asso.id}`) + .withBody(validBody) + .expectAppError(ERROR_CODE.FORBIDDEN_ASSOS_PERMISSIONS, asso.id, manageInfosPermission.id)); + + it('should return a 401 as description is non lexical', () => + pactum + .spec() + .withBearerToken(userAllowed.token) + .patch(`/assos/${asso.id}`) + .withBody({ ...validBody, description: { fr: nonLexicalText } }) + .expectAppError(ERROR_CODE.PARAM_LEXICAL_ILLEGAL, 'description.fr')); + + it('should return a 404 as media does not exist', () => + pactum + .spec() + .withBearerToken(userAllowed.token) + .patch(`/assos/${asso.id}`) + .withBody({ ...validBody, logo: Dummies.UUID }) + .expectAppError(ERROR_CODE.NO_SUCH_MEDIA, Dummies.UUID)); + + it('should return a 404 as media is not public', () => + pactum + .spec() + .withBearerToken(userAllowed.token) + .patch(`/assos/${asso.id}`) + .withBody({ ...validBody, logo: nonPublicMedia.id }) + .expectAppError(ERROR_CODE.MEDIA_NOT_PUBLIC)); + + it('should return a 404 as media is not public', () => + pactum + .spec() + .withBearerToken(userAllowed.token) + .patch(`/assos/${asso.id}`) + .withBody({ ...validBody, logo: nonAvatarMedia.id }) + .expectAppError(ERROR_CODE.MEDIA_PRESET_REQUIRED, 'AVATAR')); + + it('should return a 404 as media is not public', () => + pactum + .spec() + .withBearerToken(userAllowed.token) + .patch(`/assos/${asso.id}`) + .withBody({ ...validBody, logo: nonAvatarMedia.id }) + .expectAppError(ERROR_CODE.MEDIA_PRESET_REQUIRED, 'AVATAR')); + + it('should update the asso', async () => { + await pactum + .spec() + .withBearerToken(userAllowed.token) + .patch(`/assos/${asso.id}`) + .withBody({ ...validBody, logo: publicMedia.id }) + .expectAsso({ + ...asso, + ...pick(validBody, 'name', 'phoneNumber', 'website'), + descriptionShortTranslation: validBody.descriptionShort, + descriptionTranslation: validBody.description, + mail: validBody.email, + logoMediaId: publicMedia.id, + }); + await app() + .get(PrismaService) + .asso.update({ + where: { id: asso.id }, + data: { + ...pick(asso, 'name', 'mail', 'phoneNumber', 'website'), + logo: { disconnect: true }, + descriptionTranslation: { update: validBody.description }, + descriptionShortTranslation: { update: validBody.descriptionShort }, + }, + }); + }); + + it('should update the asso and link media to description and ignore invalid media', async () => { + const lexicalTextWithImage = `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"I want you as a ","type":"color-text","version":1},{"detail":0,"format":1,"mode":"normal","style":"","text":"lexical","type":"color-text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" text that passes the ","type":"color-text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":"tests","type":"color-text","version":1,"color":"blue"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"color-text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"type":"image","version":1,"src":"https://etu.utt.fr/api/v1/media/image/${publicMedia.id}.webp","altText":"","width":1920,"height":1080}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"type":"image","version":1,"src":"https://etu.utt.fr/api/v1/media/image/${Dummies.UUID}.webp","altText":"","width":1920,"height":1080}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`; + await pactum + .spec() + .withBearerToken(userAllowed.token) + .patch(`/assos/${asso.id}`) + .withBody({ ...validBody, description: { fr: lexicalTextWithImage }, logo: publicMedia.id }) + .expectAsso({ + ...asso, + ...pick(validBody, 'name', 'phoneNumber', 'website'), + descriptionShortTranslation: validBody.descriptionShort, + descriptionTranslation: { fr: lexicalTextWithImage }, + mail: validBody.email, + logoMediaId: publicMedia.id, + }); + const { descriptionImages } = await app() + .get(PrismaService) + .asso.findUnique({ where: { id: asso.id }, include: { descriptionImages: true } }); + expect(descriptionImages.length).toBe(1); + return app() + .get(PrismaService) + .asso.update({ + where: { id: asso.id }, + data: { + ...pick(asso, 'name', 'mail', 'phoneNumber', 'website'), + logo: { disconnect: true }, + descriptionImages: { disconnect: { id: publicMedia.id } }, + descriptionTranslation: { update: validBody.description }, + descriptionShortTranslation: { update: validBody.descriptionShort }, + }, + }); + }); +}); + +export default UpdateAssoE2ESpec; diff --git a/test/e2e/media/image/artifacts/image.avif b/test/e2e/media/image/artifacts/image.avif new file mode 100644 index 00000000..3277d9cb Binary files /dev/null and b/test/e2e/media/image/artifacts/image.avif differ diff --git a/test/e2e/media/image/artifacts/image.gif b/test/e2e/media/image/artifacts/image.gif new file mode 100644 index 00000000..0857f028 Binary files /dev/null and b/test/e2e/media/image/artifacts/image.gif differ diff --git a/test/e2e/media/image/artifacts/image.gif.png b/test/e2e/media/image/artifacts/image.gif.png new file mode 100644 index 00000000..0857f028 Binary files /dev/null and b/test/e2e/media/image/artifacts/image.gif.png differ diff --git a/test/e2e/media/image/artifacts/image.jpg b/test/e2e/media/image/artifacts/image.jpg new file mode 100644 index 00000000..e5cc26cf Binary files /dev/null and b/test/e2e/media/image/artifacts/image.jpg differ diff --git a/test/e2e/media/image/artifacts/image.png b/test/e2e/media/image/artifacts/image.png new file mode 100644 index 00000000..96fa3533 Binary files /dev/null and b/test/e2e/media/image/artifacts/image.png differ diff --git a/test/e2e/media/image/artifacts/image.tif b/test/e2e/media/image/artifacts/image.tif new file mode 100644 index 00000000..d54802d4 Binary files /dev/null and b/test/e2e/media/image/artifacts/image.tif differ diff --git a/test/e2e/media/image/artifacts/image.webp b/test/e2e/media/image/artifacts/image.webp new file mode 100644 index 00000000..c7239776 Binary files /dev/null and b/test/e2e/media/image/artifacts/image.webp differ diff --git a/test/e2e/media/image/get-media.e2e-spec.ts b/test/e2e/media/image/get-media.e2e-spec.ts new file mode 100644 index 00000000..09ed3732 --- /dev/null +++ b/test/e2e/media/image/get-media.e2e-spec.ts @@ -0,0 +1,56 @@ +import { Dummies, e2eSuite } from '../../../utils/test_utils'; +import * as fakedb from '../../../utils/fakedb'; +import { cpSync, mkdirSync, rmSync } from 'fs'; +import { ConfigModule } from '../../../../src/config/config.module'; +import { createUser } from '../../../utils/fakedb'; +import { ERROR_CODE } from '../../../../src/exceptions'; +import * as pactum from 'pactum'; + +export const GetMediaE2ESpec = e2eSuite('GET /media/image/:mediaId', (app) => { + const user = createUser(app); + const publicMedia = fakedb.createImageMedia(app, { isPublic: true }); + const nonPublicMedia = fakedb.createImageMedia(app, { isPublic: false }); + const publicMediaInError = fakedb.createImageMedia(app, { isPublic: true }); + + beforeAll(() => { + mkdirSync(`${app().get(ConfigModule).MEDIA_UPLOAD_DIR}/image`, { recursive: true }); + cpSync( + `test/e2e/media/image/artifacts/image.webp`, + `${app().get(ConfigModule).MEDIA_UPLOAD_DIR}/image/${publicMedia.id}.webp`, + ); + cpSync( + `test/e2e/media/image/artifacts/image.webp`, + `${app().get(ConfigModule).MEDIA_UPLOAD_DIR}/image/${nonPublicMedia.id}.webp`, + ); + }); + + afterAll(() => { + rmSync(app().get(ConfigModule).MEDIA_UPLOAD_DIR.split('/')[0], { recursive: true }); + }); + + it('should return a 404 as the media does not exist', () => + pactum.spec().get(`/media/image/${Dummies.UUID}.webp`).expectAppError(ERROR_CODE.NO_SUCH_MEDIA, Dummies.UUID)); + + it('should return a 401 as the media is not public', () => + pactum.spec().get(`/media/image/${nonPublicMedia.id}.webp`).expectAppError(ERROR_CODE.NOT_LOGGED_IN)); + + it('should return a 200 and the media (public)', () => + pactum + .spec() + .get(`/media/image/${publicMedia.id}.webp`) + .expectStatus(200) + .expectHeader('content-type', 'image/webp') + .expectBodyContains('RIFF')); + + it('should return a 200 and the media (not public)', () => + pactum + .spec() + .withBearerToken(user.token) + .get(`/media/image/${nonPublicMedia.id}.webp`) + .expectStatus(200) + .expectHeader('content-type', 'image/webp') + .expectBodyContains('RIFF')); + + it('should return a 503 as there is an error reading the file', () => + pactum.spec().get(`/media/image/${publicMediaInError.id}.webp`).expectAppError(ERROR_CODE.SERVER_DISK_ERROR)); +}); diff --git a/test/e2e/media/image/upload-media.e2e-spec.ts b/test/e2e/media/image/upload-media.e2e-spec.ts new file mode 100644 index 00000000..aa85d376 --- /dev/null +++ b/test/e2e/media/image/upload-media.e2e-spec.ts @@ -0,0 +1,142 @@ +import { ImageMediaPreset } from '@prisma/client'; +import { mkdirSync, rmSync } from 'fs'; +import { ERROR_CODE } from '../../../../src/exceptions'; +import { createUser } from '../../../utils/fakedb'; +import { e2eSuite, JsonLike } from '../../../utils/test_utils'; +import { ConfigModule } from '../../../../src/config/config.module'; +import { PermissionManager } from '../../../../src/utils'; +import * as pactum from 'pactum'; + +export const UploadMediaE2ESpec = e2eSuite('POST /media/image', (app) => { + const user = createUser(app, { permissions: new PermissionManager().with('API_UPLOAD_MEDIA') }); + const unauthorizedUser = createUser(app); + + const params = { + public: true, + preset: ImageMediaPreset.AVATAR, + rotation: 1, + effort: 2, + quality: 100, + width: 150, + height: 150, + }; + + it('should return a 401 as user is not authenticated', () => { + return pactum.spec().post(`/media/image`).expectAppError(ERROR_CODE.NOT_LOGGED_IN); + }); + + it('should fail as the user does not have the required permissions', () => + pactum + .spec() + .withBearerToken(unauthorizedUser.token) + .post(`/media/image`) + .withQueryParams(params) + .withFile('file', `test/e2e/media/image/artifacts/image.png`) + .expectAppError(ERROR_CODE.FORBIDDEN_NOT_ENOUGH_API_PERMISSIONS, 'API_UPLOAD_MEDIA')); + + it('should fail as directory does not exist', () => + pactum + .spec() + .withBearerToken(user.token) + .post(`/media/image`) + .withQueryParams(params) + .withFile('file', `test/e2e/media/image/artifacts/image.webp`) + .expectAppError(ERROR_CODE.SERVER_DISK_ERROR)); + + describe('should create the annal', () => { + beforeAll(() => { + mkdirSync(`${app().get(ConfigModule).MEDIA_UPLOAD_DIR}/image`, { recursive: true }); + }); + + afterAll(() => { + rmSync(app().get(ConfigModule).MEDIA_UPLOAD_DIR.split('/')[0], { recursive: true }); + }); + + const testFunction = (fileExt: 'png' | 'jpg' | 'avif' | 'tif' | 'webp', rotation: 0 | 1 | 2 | 3) => async () => { + return pactum + .spec() + .withBearerToken(user.token) + .post(`/media/image`) + .withQueryParams({ ...params, rotation }) + .withFile('file', `test/e2e/media/image/artifacts/image.${fileExt}`) + .expectImageMedia({ + width: 256, + height: 256, + isPublic: params.public, + preset: params.preset, + size: JsonLike.INT, + id: JsonLike.UUID, + }); + }; + + it('from a tiff', testFunction('tif', 0)); + it('from a png', testFunction('png', 1)); + it('from a jpg', testFunction('jpg', 2)); + it('from a webp', testFunction('webp', 3)); + it('from a avif', testFunction('avif', 0)); + + it('should upscale to 1080p', async () => { + return pactum + .spec() + .withBearerToken(user.token) + .post(`/media/image`) + .withQueryParams({ + ...params, + preset: 'CUSTOM', + width: 1920, + height: 1080, + }) + .withFile('file', `test/e2e/media/image/artifacts/image.png`) + .expectImageMedia({ + width: 1920, + height: 1080, + isPublic: params.public, + preset: 'CUSTOM', + size: JsonLike.INT, + id: JsonLike.UUID, + }); + }); + + it('should not upscale to more than 1080p', async () => { + return pactum + .spec() + .withBearerToken(user.token) + .post(`/media/image`) + .withQueryParams({ + ...params, + preset: 'CUSTOM', + width: 1921, + height: 1081, + }) + .withFile('file', `test/e2e/media/image/artifacts/image.png`) + .expectAppError(ERROR_CODE.PARAM_TOO_HIGH, 'height, width'); + }); + + it('not from a gif', async () => { + return pactum + .spec() + .withBearerToken(user.token) + .post(`/media/image`) + .withQueryParams(params) + .withFile('file', `test/e2e/media/image/artifacts/image.gif`) + .expectAppError(ERROR_CODE.FILE_INVALID_TYPE, 'image/png, image/jpeg, image/webp, image/avif, image/tiff'); + }); + it('not from a fake png', async () => { + return pactum + .spec() + .withBearerToken(user.token) + .post(`/media/image`) + .withQueryParams(params) + .withFile('file', `test/e2e/media/image/artifacts/image.gif.png`) + .expectAppError(ERROR_CODE.FILE_INVALID_TYPE, 'image/png, image/jpeg, image/webp, image/avif, image/tiff'); + }); + it('but not allow missing files', async () => { + return pactum + .spec() + .withBearerToken(user.token) + .post(`/media/image`) + .withQueryParams(params) + .expectAppError(ERROR_CODE.NO_FILE_PROVIDED); + }); + }); +}); diff --git a/test/e2e/media/index.ts b/test/e2e/media/index.ts new file mode 100644 index 00000000..01700cc3 --- /dev/null +++ b/test/e2e/media/index.ts @@ -0,0 +1,10 @@ +import { E2EAppProvider } from '../../utils/test_utils'; +import { GetMediaE2ESpec } from './image/get-media.e2e-spec'; +import { UploadMediaE2ESpec } from './image/upload-media.e2e-spec'; + +export default function MediaE2ESpec(app: E2EAppProvider) { + describe('Media', () => { + GetMediaE2ESpec(app); + UploadMediaE2ESpec(app); + }); +} diff --git a/test/e2e/users/get-user_assos-e2e-spec.ts b/test/e2e/users/get-user_assos-e2e-spec.ts index ab1bbce3..94200bea 100644 --- a/test/e2e/users/get-user_assos-e2e-spec.ts +++ b/test/e2e/users/get-user_assos-e2e-spec.ts @@ -59,6 +59,7 @@ const GetUserAssociationE2ESpec = e2eSuite('GET /users/:userId/associations', (a asso: { ...omit(membership.asso, 'descriptionShortTranslation'), shortDescription: membership.asso.descriptionShortTranslation.fr, + logo: membership.asso.logo ? `/media/image/${membership.asso.logo.id}.webp` : null, }, })); diff --git a/test/e2e/users/update-profile-e2e-spec.ts b/test/e2e/users/update-profile-e2e-spec.ts index 56fba51b..b027e18d 100644 --- a/test/e2e/users/update-profile-e2e-spec.ts +++ b/test/e2e/users/update-profile-e2e-spec.ts @@ -32,7 +32,7 @@ const UpdateProfile = e2eSuite('PATCH /users/current', (app) => { displayAddress: 'ALL_PUBLIC', }) .$expectRegexableJson({ - avatar: user.infos.avatar, + avatar: user.infos.avatarMediaId ? `/media/image/${user.infos.avatarMediaId}.webp` : null, birthday: user.infos.birthday, discord: user.socialNetwork.discord, facebook: 'fbProfile', diff --git a/test/jest.json b/test/jest.json index 3923f8c6..f6ca26d2 100644 --- a/test/jest.json +++ b/test/jest.json @@ -7,5 +7,7 @@ "transform": { "^.+\\.(t|j)s$": "ts-jest" }, - "collectCoverageFrom": ["**/*.ts", "!main.ts"] + "collectCoverageFrom": ["**/*.ts", "!main.ts", "!**/*-res.dto.ts"], + "coverageDirectory": "coverage", + "coverageReporters": ["html", "lcov", "text-summary"] } diff --git a/test/unit/app.spec.ts b/test/unit/app.spec.ts index 42372ff3..0fe934af 100644 --- a/test/unit/app.spec.ts +++ b/test/unit/app.spec.ts @@ -1,15 +1,16 @@ import TimetableServiceUnitSpec from './timetable/timetable.service.spec'; +import LexicalValidationUnitSpec from './lexical/lexical-validation.spec'; +import LexicalGenerationUnitSpec from './lexical/lexical-generation.spec'; import { Test, TestingModule } from '@nestjs/testing'; import { AppModule } from '../../src/app.module'; import '../../src/std.type'; -/* - * Unit testing is currently DISABLED. Remove the .skip in the line below - */ -describe.skip('EtuUTT API unit testing', () => { +describe('EtuUTT API unit testing', () => { let app: TestingModule; beforeAll(async () => { app = await Test.createTestingModule({ imports: [AppModule] }).compile(); }); TimetableServiceUnitSpec(() => app); + LexicalValidationUnitSpec(() => app); + LexicalGenerationUnitSpec(() => app); }); diff --git a/test/unit/lexical/lexical-generation.spec.ts b/test/unit/lexical/lexical-generation.spec.ts new file mode 100644 index 00000000..8b98acf2 --- /dev/null +++ b/test/unit/lexical/lexical-generation.spec.ts @@ -0,0 +1,55 @@ +import { unitSuite } from '../../utils/test_utils'; +import { BUNDLES, LexicalModule } from '../../../src/lexical/lexical.module'; +import { createHeadlessEditor } from '@lexical/headless'; +import { $createParagraphNode, $createTextNode, $getRoot, LexicalEditor } from 'lexical'; + +const LexicalGenerationUnitSpec = unitSuite('Lexical generation', (app) => { + let lexicalModule: LexicalModule; + + beforeAll(() => { + lexicalModule = app().get(LexicalModule); + }); + + const checkExportForBundles = ( + name: string, + bundles: (keyof typeof BUNDLES)[], + test: (editor: LexicalEditor) => void, + result: string, + ) => { + bundles.forEach((bundle) => { + // These steps are skipped as jest does not support yet pure-esm sub-dependencies + describe.skip(name, () => { + it(bundle, () => { + const editor = createHeadlessEditor({ + nodes: BUNDLES['@etuutt/full'], + }); + editor.update(() => { + test(editor); + }); + editor.read(async () => + expect(await lexicalModule.generateHTML(JSON.stringify(editor.getEditorState()), bundle)).toBe(result), + ); + }); + }); + }); + }; + + checkExportForBundles( + 'Paragraph', + ['@etuutt/full', '@etuutt/simple'], + () => $getRoot().append($createParagraphNode().append($createTextNode('Hello World'))), + '

Hello World

', + ); + + checkExportForBundles( + 'Bold text', + ['@etuutt/full', '@etuutt/simple'], + () => + $getRoot().append( + $createParagraphNode().append($createTextNode('Hello '), $createTextNode('World').setFormat('bold')), + ), + '

Hello World

', + ); +}); + +export default LexicalGenerationUnitSpec; diff --git a/test/unit/lexical/lexical-validation.spec.ts b/test/unit/lexical/lexical-validation.spec.ts new file mode 100644 index 00000000..7a207b64 --- /dev/null +++ b/test/unit/lexical/lexical-validation.spec.ts @@ -0,0 +1,157 @@ +import { unitSuite } from '../../utils/test_utils'; +import { BUNDLES, LexicalModule } from '../../../src/lexical/lexical.module'; +import { createHeadlessEditor } from '@lexical/headless'; +import { $createParagraphNode, $createTextNode, $getRoot, LexicalEditor } from 'lexical'; +import { $createCodeHighlightNode, $createCodeNode } from '@lexical/code'; +import { $createHeadingNode, $createQuoteNode } from '@lexical/rich-text'; +import { $createImageNode } from '../../../src/lexical/nodes/ImageNode'; +import { $createColorTextNode } from '../../../src/lexical/nodes/ColorTextNode'; +import { $createAutoLinkNode, $createLinkNode } from '@lexical/link'; +import { $createHorizontalRuleNode } from '@lexical/extension'; +import { $createListItemNode, $createListNode } from '@lexical/list'; +import { $createTableCellNode, $createTableNode, $createTableRowNode } from '@lexical/table'; + +const LexicalValidationUnitSpec = unitSuite('Lexical validation', (app) => { + let lexicalModule: LexicalModule; + + beforeAll(() => { + lexicalModule = app().get(LexicalModule); + }); + + const checkValidityForBundles = ( + name: string, + bundles: Record, + test: (editor: LexicalEditor) => void, + ) => { + Object.entries(bundles).forEach(([bundle, shouldBeValid]) => { + describe(name, () => { + it(bundle, () => { + const editor = createHeadlessEditor({ + nodes: BUNDLES['@etuutt/full'], + }); + editor.update(() => { + test(editor); + }); + editor.read(() => + expect(lexicalModule.isValidLexicalContent(JSON.stringify(editor.getEditorState()), bundle)).toBe( + shouldBeValid, + ), + ); + }); + }); + }); + }; + + checkValidityForBundles( + 'Paragraph', + { + '@etuutt/full': true, + '@etuutt/simple': true, + }, + () => $getRoot().append($createParagraphNode().append($createTextNode('Hello World'))), + ); + + checkValidityForBundles( + 'Paragraph with color', + { + '@etuutt/full': true, + '@etuutt/simple': false, + }, + () => $getRoot().append($createParagraphNode().append($createColorTextNode('Hello World').setColor('blue'))), + ); + + checkValidityForBundles( + 'Paragraph with link', + { + '@etuutt/full': true, + '@etuutt/simple': false, + }, + () => + $getRoot().append( + $createParagraphNode().append($createTextNode('Hello '), $createLinkNode('World').setURL('https://etu.utt.fr')), + ), + ); + + checkValidityForBundles( + 'Paragraph with autolink', + { + '@etuutt/full': true, + '@etuutt/simple': false, + }, + () => + $getRoot().append( + $createParagraphNode().append( + $createTextNode('Hello '), + $createAutoLinkNode('World').setURL('https://etu.utt.fr'), + ), + ), + ); + + checkValidityForBundles( + 'Heading', + { + '@etuutt/full': true, + '@etuutt/simple': false, + }, + () => $getRoot().append($createHeadingNode().append($createTextNode('Hello heading'))), + ); + + checkValidityForBundles( + 'Quote', + { + '@etuutt/full': true, + '@etuutt/simple': false, + }, + () => $getRoot().append($createQuoteNode().append($createTextNode('Hello quote'))), + ); + + checkValidityForBundles( + 'Code', + { + '@etuutt/full': true, + '@etuutt/simple': false, + }, + () => $getRoot().append($createCodeNode().append($createTextNode('Hello '), $createCodeHighlightNode('highlight'))), + ); + + checkValidityForBundles( + 'Image', + { + '@etuutt/full': true, + '@etuutt/simple': false, + }, + () => $getRoot().append($createParagraphNode().append($createImageNode('https://etu.utt.fr/test.webp'))), + ); + + checkValidityForBundles( + 'Horizontal rule', + { + '@etuutt/full': true, + '@etuutt/simple': false, + }, + () => $getRoot().append($createHorizontalRuleNode()), + ); + + checkValidityForBundles( + 'List', + { + '@etuutt/full': true, + '@etuutt/simple': false, + }, + () => $getRoot().append($createListNode().append($createListItemNode().append($createTextNode('Item')))), + ); + + checkValidityForBundles( + 'Table', + { + '@etuutt/full': true, + '@etuutt/simple': false, + }, + () => + $getRoot().append( + $createTableNode().append($createTableRowNode().append($createTableCellNode().append($createTextNode('Cell')))), + ), + ); +}); + +export default LexicalValidationUnitSpec; diff --git a/test/unit/timetable/timetable.service.spec.ts b/test/unit/timetable/timetable.service.spec.ts index e7dd9ee1..0e689a84 100644 --- a/test/unit/timetable/timetable.service.spec.ts +++ b/test/unit/timetable/timetable.service.spec.ts @@ -5,7 +5,8 @@ import * as fakedb from '../../utils/fakedb'; import { createTimetableEntry, createTimetableEntryOverride } from '../../utils/fakedb'; import { faker } from '@faker-js/faker'; -const TimetableServiceUnitSpec = unitSuite('Timetable.service', (app) => { +// This check is skipped, please remove the last argument to enable tests +const TimetableServiceUnitSpec = unitSuite.skip('Timetable.service', (app) => { let timetableService: TimetableService; let prisma: PrismaService; const user1 = fakedb.createUser(app); diff --git a/test/utils/fakedb.ts b/test/utils/fakedb.ts index c368ac9b..99a8c397 100644 --- a/test/utils/fakedb.ts +++ b/test/utils/fakedb.ts @@ -34,12 +34,13 @@ import { RawUserPrivacy, RawApiKey, RawApiApplication, + RawImageMedia, } from '../../src/prisma/types'; import { faker } from '@faker-js/faker'; import { AuthService } from '../../src/auth/auth.service'; import { PrismaService } from '../../src/prisma/prisma.service'; import { AppProvider } from './test_utils'; -import { Permission, Sex, TimetableEntryType, UserType } from '@prisma/client'; +import { ImageMediaPreset, Permission, Sex, TimetableEntryType, UserType } from '@prisma/client'; import { CommentStatus } from '../../src/ue/comments/interfaces/comment.interface'; import { UeAnnalFile } from '../../src/ue/annals/interfaces/annal.interface'; import { omit, PermissionManager, pick, translationSelect } from '../../src/utils'; @@ -118,6 +119,7 @@ export type FakeHomepageWidget = Partial; export type FakeApiApplication = Partial> & { owner: { id: string; firstName: string; lastName: string }; }; +export type FakeImageMedia = Partial; export interface FakeEntityMap { assoMembership: { @@ -245,6 +247,10 @@ export interface FakeEntityMap { params: CreateApiApplicationParameter; deps: { owner: FakeUser }; }; + imageMedia: { + entity: FakeImageMedia; + params: CreateImageMediaParameter; + }; } export type CreateUserParameters = FakeUser & { password: string }; @@ -1090,6 +1096,19 @@ export const createApplication = entityFaker( }), ); +export type CreateImageMediaParameter = Omit; +export const createImageMedia = entityFaker( + 'imageMedia', + { + height: faker.number.int({ min: 100, max: 4000 }), + width: faker.number.int({ min: 100, max: 4000 }), + isPublic: faker.datatype.boolean, + size: faker.number.int({ min: 1000, max: 10_000_000 }), + preset: faker.helpers.enumValue(ImageMediaPreset), + }, + async (app, params) => app().get(PrismaService).imageMedia.create({ data: params }), +); + /** * The return type of a fake function, either Promise or FakeEntity depending on whether OnTheFly is true or false */ diff --git a/test/utils/test_utils.ts b/test/utils/test_utils.ts index d338062d..99384b61 100644 --- a/test/utils/test_utils.ts +++ b/test/utils/test_utils.ts @@ -3,7 +3,6 @@ import { INestApplication } from '@nestjs/common'; import { TestingModule } from '@nestjs/testing'; import { faker } from '@faker-js/faker'; import { ConfigModule } from '../../src/config/config.module'; -import { DMMF } from '@prisma/client/runtime/library'; import { clearUniqueValues, generateDefaultApplication } from '../../prisma/seed/utils'; import { PrismaClient } from '@prisma/client'; @@ -52,6 +51,12 @@ function suite(name: string, func: (app: T) => void) { func(app); }); } +suite.skip = + (name: string, func: (app: T) => void) => + (app: T) => + describe.skip(name, () => { + func(app); + }); /** * Creates a suite for e2e testing. It works the same as {@link describe}, but it cleans the database before each suite. @@ -69,6 +74,7 @@ export const unitSuite = suite; export const JsonLike = { STRING: Symbol('string'), UUID: Symbol('uuid'), + INT: Symbol('int'), DATE: /^\d{4}-[01]\d-[0-3]\d(?:T[0-2]\d:[0-5]\d:[0-5]\d[.,]\d+Z)?$/, // dateTime from pactum-matchers doesn't ms }; @@ -81,46 +87,9 @@ export const Dummies = { * @param prisma The prisma service instance. */ export async function cleanDb(prisma: PrismaService | PrismaClient) { - // We can't delete each table one by one, because of foreign key constraints - const tablesCleared = [] as string[]; + await prisma.$executeRawUnsafe(`SET FOREIGN_KEY_CHECKS = 0`); // _runtimeDataModel.models basically contains a JS-ified version of the schema.prisma - for (const modelName of Object.keys((prisma as any)._runtimeDataModel.models) as string[]) { - // Check the table hasn't been already cleaned - if (tablesCleared.includes(modelName)) continue; - await clearTableWithCascade(prisma, modelName, tablesCleared); - } -} - -/** - * Clears a table, and all the tables that have a foreign key constraint on it. - * This should only be used by {@link cleanDb}. - * @param prisma The prisma service instance. - * @param modelName The name of the model to clear. - * @param tablesCleared The list of tables that have already been cleared. - */ -async function clearTableWithCascade(prisma: PrismaService | PrismaClient, modelName: string, tablesCleared: string[]) { - // No, the full type of the model is not even exported :( - // (type RuntimeDataModel in prisma/client/runtime/library) - const model: Omit = (prisma as any)._runtimeDataModel.models[modelName]; - for (const field of Object.values(model.fields)) { - // First, check that the field is a relation, and not a normal String, or Int, or any normal SQL type - // We then check that this is not a self-referencing relation, to avoid infinite loops - // The way we verify that this is not the part of the relation that is referenced is by checking the length of relationFromFields : if it has a length, the table contains the FK, if not, that's the other table - // Plot twist : Prisma allows for ManyToMany relations. That means that, to avoid infinitely looping, we verify the other relation in the opposite direction (with the same name) holds the FK - if ( - field.kind === 'object' && - field.type !== modelName && - field.relationFromFields.length === 0 && - !tablesCleared.includes(field.type) && - (prisma as any)._runtimeDataModel.models[field.type].fields.find( - (f: DMMF.Field) => f.relationName === field.relationName, - ).relationFromFields.length !== 0 - ) { - // After all these checks, simply delete rows from the other table first to avoid foreign key constraint errors - await clearTableWithCascade(prisma, field.type, tablesCleared); - } - } - // And finally, once it's safe to do it, delete the rows, and mark it as cleared - await prisma[modelName].deleteMany(); - tablesCleared.push(modelName); + for (const modelName of Object.keys((prisma as any)._runtimeDataModel.models) as string[]) + await prisma[modelName].deleteMany(); + await prisma.$executeRawUnsafe(`SET FOREIGN_KEY_CHECKS = 1`); } diff --git a/tsconfig.json b/tsconfig.json index e3d90c59..7d4a0aeb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,10 +6,13 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, - "target": "ES2022", + "target": "es2022", "sourceMap": true, "outDir": "./dist", - "baseUrl": "./", + "paths": { + "src/*": ["./src/*"], + "test/*": ["./test/*"] + }, "incremental": true, "skipLibCheck": true, "strictNullChecks": false, @@ -18,9 +21,9 @@ "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": false, "esModuleInterop": true, - "resolveJsonModule": true, + "resolveJsonModule": true }, "ts-node": { "files": true - }, + } }