Compare commits
445 Commits
redesign
...
sound-expe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0db8d49618 | ||
|
|
4923fc72b2 | ||
|
|
60a73781bd | ||
|
|
4e3b378d6a | ||
|
|
2bf1e783a9 | ||
|
|
375d36f6c5 | ||
|
|
495380ec43 | ||
|
|
af0531bb0c | ||
|
|
c5124fa6ad | ||
|
|
962cf58e77 | ||
|
|
60988dd599 | ||
|
|
004a541302 | ||
|
|
f4cd9e18ea | ||
|
|
2dc301addf | ||
|
|
e85a959c26 | ||
|
|
339e808d27 | ||
|
|
22d1a97abd | ||
|
|
ce4baa61dc | ||
|
|
e90c7f05a8 | ||
|
|
fb0e43af88 | ||
|
|
4577997b1c | ||
|
|
9bb256f2ee | ||
|
|
d3994ff26e | ||
|
|
00d097f643 | ||
|
|
99da77f23e | ||
|
|
7a6dc8b0c9 | ||
|
|
b4f04f9b71 | ||
|
|
9df0c98100 | ||
|
|
a47f8744f8 | ||
| 80329e240e | |||
| 70b109e78b | |||
| a6a96d6a1e | |||
| cc4b69ca50 | |||
|
|
e733c6acc8 | ||
|
|
afa6f421d3 | ||
|
|
7d7f886661 | ||
|
|
fd22b8afe5 | ||
|
|
1c1596b44a | ||
|
|
9b05dc3ae3 | ||
|
|
d717269563 | ||
|
|
cba8815cfc | ||
|
|
647f7b2676 | ||
|
|
ef4f2355bf | ||
|
|
24a226b283 | ||
|
|
81717ec5b1 | ||
|
|
f9cb289eff | ||
|
|
022490ae10 | ||
|
|
ca4818c070 | ||
|
|
fe8e9cb262 | ||
|
|
9683d83298 | ||
|
|
69d9a4c499 | ||
| 7678776872 | |||
|
|
f590b573fb | ||
|
|
2c9ec4a7d3 | ||
|
|
393782b4b8 | ||
|
|
c33e1bbaa3 | ||
|
|
63a9271617 | ||
|
|
6469d4763a | ||
|
|
922e36093e | ||
|
|
81976206f9 | ||
|
|
4ac6369deb | ||
|
|
dc0c7fa4e7 | ||
|
|
61ebf58631 | ||
|
|
1d61b1e652 | ||
|
|
d0f9c4a032 | ||
|
|
27119056a4 | ||
|
|
044dd59d8f | ||
|
|
e5ab9b9310 | ||
|
|
f11cddf55a | ||
|
|
f076bf9794 | ||
|
|
fe510e148a | ||
|
|
0a84c9daac | ||
|
|
f496ae5bc1 | ||
|
|
1379cbd3f6 | ||
|
|
ab221bd393 | ||
|
|
e5fb1dfb7e | ||
|
|
c113b70fee | ||
|
|
d2a8f9a1ef | ||
|
|
ee56a53b40 | ||
| ece93f79b2 | |||
| 14e241db37 | |||
| 3becdcff46 | |||
| c0bc611268 | |||
| eff5eae706 | |||
| 59a48ad060 | |||
|
|
d3f7eded41 | ||
|
|
bbf3a317ec | ||
|
|
c6365113c4 | ||
|
|
454835338f | ||
|
|
3f0c2472cb | ||
| a36afa3a47 | |||
| 9bce8d74c9 | |||
|
|
e5acd56b0f | ||
|
|
685e79d76b | ||
|
|
183dee193c | ||
|
|
7167b49edc | ||
|
|
8b465731f0 | ||
|
|
0e26dbfc65 | ||
|
|
347c075ab1 | ||
|
|
01829c7b8b | ||
|
|
e148f9edb8 | ||
|
|
8a00b99f9a | ||
|
|
4d16723e38 | ||
|
|
683984efe9 | ||
|
|
6018028afd | ||
|
|
eac21844c4 | ||
|
|
0cb8dd2693 | ||
|
|
a4a10eb7f2 | ||
|
|
ff4926fa80 | ||
|
|
dd581a8418 | ||
|
|
5f0d7dda59 | ||
|
|
dfdbbdc51c | ||
|
|
72f17c018e | ||
|
|
397dfbcf5f | ||
|
|
df682327d6 | ||
|
|
46d5614e4c | ||
|
|
7e1f03af57 | ||
|
|
b54032fe63 | ||
|
|
b417076ee6 | ||
|
|
95da2cc500 | ||
|
|
84ea0b3743 | ||
|
|
00433ee7ba | ||
|
|
7f282e2ec5 | ||
|
|
3b89387b12 | ||
|
|
3b24cefd3f | ||
|
|
4de420e4dc | ||
|
|
36041369db | ||
|
|
b33ff55167 | ||
|
|
e8e6012bf2 | ||
|
|
92169bf485 | ||
|
|
9f57e8ac67 | ||
|
|
262353376c | ||
|
|
fd50b2268b | ||
|
|
6839cda5b8 | ||
|
|
d2aca488ad | ||
|
|
1fe7491bcd | ||
|
|
6a8fe074e0 | ||
|
|
624b640e01 | ||
|
|
ce4e09f1f6 | ||
|
|
c085e9aa22 | ||
|
|
dc491983f5 | ||
|
|
a0587fbad6 | ||
|
|
702caed232 | ||
|
|
cb65e08465 | ||
|
|
c1e862e6bd | ||
|
|
533dc0e7ad | ||
|
|
ecac53516e | ||
|
|
9133a369d5 | ||
|
|
4c580f1693 | ||
|
|
732f8e2577 | ||
|
|
4b3ec157c2 | ||
|
|
58de04924e | ||
|
|
66048ca793 | ||
|
|
b0b5579cb3 | ||
|
|
005cc7410f | ||
|
|
a4c2c4932d | ||
|
|
617d31cb22 | ||
|
|
384fb10f54 | ||
|
|
72c615ffed | ||
|
|
ce2da1d859 | ||
|
|
9d6beb74c0 | ||
|
|
1f25521900 | ||
|
|
f5c0d6967b | ||
|
|
c5e5519426 | ||
|
|
4c98759ded | ||
|
|
c910b0e617 | ||
|
|
94218558a7 | ||
|
|
f1662ca18b | ||
|
|
4a5658c4ca | ||
|
|
61ed8855ea | ||
|
|
94838ef1fc | ||
|
|
3ce69228a8 | ||
|
|
c522258d04 | ||
|
|
f91ab4c430 | ||
|
|
57cba61d1b | ||
|
|
3fbcb23089 | ||
|
|
9b0c633a87 | ||
|
|
c91bbfd2f1 | ||
|
|
9882fd240e | ||
|
|
ea6073eb71 | ||
|
|
22722082eb | ||
|
|
36316b0333 | ||
|
|
a814eec2cf | ||
|
|
4c96f78a46 | ||
|
|
cc65a3bd09 | ||
|
|
5f9e9f5327 | ||
|
|
b1d54d8665 | ||
|
|
d01aabe788 | ||
|
|
19d64c1bc5 | ||
|
|
ee98e6e352 | ||
|
|
bf52e7385b | ||
|
|
2d6fd3a3dc | ||
|
|
4bb5a11fff | ||
|
|
d4a758d262 | ||
|
|
9397de8cb9 | ||
|
|
d2e1ba51c6 | ||
|
|
ebed646c07 | ||
|
|
7067fb9708 | ||
|
|
e499bb2f9f | ||
|
|
b87ec1dd44 | ||
|
|
77f0c2f06f | ||
|
|
4c1891fb44 | ||
|
|
b3dade1a38 | ||
|
|
be2617e1ee | ||
|
|
35e1268f36 | ||
|
|
a8a3ed0e7b | ||
|
|
0eef957a90 | ||
|
|
6a8ca7d0fa | ||
|
|
ddd29f5530 | ||
|
|
b6e8b20168 | ||
| bfb6cf5958 | |||
| a92ca75760 | |||
| 8d8323e382 | |||
| 76d7e69d19 | |||
| be58e932a9 | |||
| 38bbe56e9b | |||
| a65ce6595a | |||
|
|
96c43bcbad | ||
|
|
ab1ad17d21 | ||
|
|
90f7890e5f | ||
|
|
5c85296810 | ||
|
|
06bfc181c7 | ||
|
|
0473665bb4 | ||
|
|
f610de3045 | ||
|
|
911e174aef | ||
|
|
6d7f46c425 | ||
|
|
b72e7a54e5 | ||
|
|
d99d134382 | ||
|
|
576675411a | ||
|
|
d214558bc4 | ||
|
|
4299a93afe | ||
|
|
920126a392 | ||
|
|
16e6a5e21b | ||
|
|
9539018b64 | ||
|
|
0081eb2acd | ||
|
|
bcb0825f5a | ||
|
|
18a3fa518c | ||
|
|
0407f5c29e | ||
|
|
6dafe2a8e9 | ||
|
|
4a8f0aa1af | ||
|
|
745b20358d | ||
|
|
0f544b31f3 | ||
|
|
76d70f3edd | ||
|
|
1c17ac8b13 | ||
|
|
232579e75b | ||
|
|
01221eda00 | ||
|
|
b73c2fef58 | ||
|
|
3c9c1b5ff7 | ||
|
|
e50b1c1344 | ||
|
|
6dfc531891 | ||
|
|
b4f268dee0 | ||
|
|
1228eb603e | ||
|
|
3ca17338e8 | ||
|
|
614ce105bd | ||
|
|
e366fa4b32 | ||
|
|
cd87451208 | ||
|
|
845c473ed5 | ||
|
|
f4d75eef73 | ||
|
|
5395bbb03a | ||
|
|
a0ca945c72 | ||
|
|
291d7698d4 | ||
|
|
e8956c50ee | ||
|
|
2d90c6eec1 | ||
|
|
0b0fd0585d | ||
|
|
6cf72dfcca | ||
|
|
a81c0b83bb | ||
|
|
b2fb497ecf | ||
|
|
450fe1e7bd | ||
|
|
fbf4dfcfa5 | ||
|
|
d251929ede | ||
| 445816dfad | |||
|
|
4a4f9e2a55 | ||
| 3860c9f72a | |||
| b02b23a978 | |||
| 5b0c1f8992 | |||
|
|
8155549031 | ||
|
|
1ca4633360 | ||
|
|
bb304fa8cd | ||
|
|
9a1f1f78cb | ||
|
|
96bb830600 | ||
|
|
1333b74001 | ||
|
|
ece87dbdb9 | ||
|
|
e82a6b1dd6 | ||
|
|
cd2e119dc6 | ||
|
|
c9928f1cce | ||
|
|
7aac3922d6 | ||
|
|
82403c811e | ||
|
|
230c60bcd0 | ||
|
|
177e903b07 | ||
|
|
a11c236753 | ||
|
|
29ef585410 | ||
|
|
f8be2c2462 | ||
|
|
7d27af1e2d | ||
|
|
258fe91ae7 | ||
|
|
711b5d583b | ||
|
|
4416808056 | ||
|
|
979c27c087 | ||
|
|
b3117886cf | ||
|
|
1c248fa479 | ||
|
|
ec62f4b085 | ||
|
|
04bad30aaa | ||
|
|
e5a52d0f94 | ||
|
|
68c6c6fa11 | ||
|
|
94a64d16e6 | ||
| 7aa7f50ecb | |||
| ee8e0e26db | |||
| 31b965e8f6 | |||
| 94658d4379 | |||
|
|
49a735631a | ||
|
|
1905daec60 | ||
|
|
7a1f4fb787 | ||
|
|
f3cdba34fb | ||
|
|
5b7cb6746d | ||
|
|
6e3e73982f | ||
|
|
8e5c65e6f2 | ||
|
|
94875d4c7f | ||
|
|
e817021ede | ||
|
|
dcca1b1f1c | ||
|
|
c0c2918e72 | ||
|
|
973f9bf5b3 | ||
|
|
162fc9148f | ||
|
|
57d646f6eb | ||
|
|
6768b0b2a6 | ||
|
|
fa14d1f979 | ||
|
|
c4ca2e509e | ||
|
|
1abfbf391f | ||
|
|
073ff033f3 | ||
|
|
23e5941700 | ||
|
|
027d450579 | ||
|
|
ad9bbbc2b9 | ||
|
|
58af78b1d3 | ||
|
|
09d2da8eec | ||
|
|
8abaaf6624 | ||
|
|
3c3697be61 | ||
|
|
073c00a35e | ||
|
|
58d761c359 | ||
|
|
aaaf73f632 | ||
|
|
f83043a9c9 | ||
|
|
cea6d8d0bc | ||
|
|
607c35b621 | ||
|
|
13d0be4586 | ||
|
|
3e1e41f117 | ||
|
|
8f9d7e4a85 | ||
|
|
1e504c8982 | ||
|
|
e56436db3a | ||
|
|
bc227fb0ea | ||
|
|
49bc4f9f45 | ||
|
|
73076c4b28 | ||
|
|
8732972b3f | ||
|
|
cd9d64e501 | ||
|
|
62bf7ec035 | ||
|
|
659f5d5d84 | ||
|
|
bbc53f04de | ||
|
|
431427d7ad | ||
|
|
611ab57c5d | ||
| bc13c10f1a | |||
| 91c9e2b295 | |||
| 585be2aa19 | |||
| 654022b48a | |||
| afab03baf8 | |||
| a52c10fc2c | |||
| f2ed598865 | |||
| 02fc8175f4 | |||
|
|
628e50a48d | ||
|
|
70ab56ce3a | ||
|
|
1fefe7912d | ||
|
|
c21f5f0659 | ||
|
|
46ef0a7f1b | ||
|
|
b43c64962a | ||
|
|
64640eda55 | ||
|
|
a6d9cb3b40 | ||
|
|
b61541f7b8 | ||
|
|
3ff523560b | ||
|
|
b61968706d | ||
|
|
2f27278d3a | ||
|
|
e1ab9fe118 | ||
|
|
b1d0415ba0 | ||
|
|
8ab85ab689 | ||
|
|
16cd794e3b | ||
|
|
f85c30a53b | ||
|
|
6da96ed886 | ||
|
|
852fbd5c87 | ||
|
|
5cec62d1b1 | ||
|
|
7e866f9826 | ||
|
|
2f50f694f3 | ||
|
|
e0f2674811 | ||
|
|
b84ee11f45 | ||
|
|
a2494ce498 | ||
|
|
b76d496034 | ||
|
|
a81d3ee34d | ||
|
|
85473ae492 | ||
|
|
9655e986ff | ||
|
|
101ea8498b | ||
|
|
7d33f85cbc | ||
|
|
66d792715e | ||
|
|
40581f4a45 | ||
|
|
2ca3fcb81a | ||
|
|
30fcacbec6 | ||
|
|
7c3289ccec | ||
|
|
7438986bcd | ||
|
|
3ac017a5f0 | ||
|
|
8e5cc1bc44 | ||
|
|
125a7faf02 | ||
|
|
c9d3ef88e7 | ||
|
|
0ba3bec5aa | ||
|
|
539c35c903 | ||
|
|
e1463d41b9 | ||
|
|
01394056a6 | ||
|
|
1396fcb39c | ||
|
|
c81f8df61c | ||
|
|
1255343b97 | ||
|
|
f7562c18bd | ||
|
|
a3676fabf8 | ||
|
|
dc398d6e06 | ||
|
|
d5da112a01 | ||
|
|
96048bd671 | ||
|
|
dcdc6b196d | ||
|
|
9f542fc9dd | ||
| 930191569f | |||
| 74cd9c0df2 | |||
| d2642b4fb8 | |||
| ebcc48cc57 | |||
| 95b08935cc | |||
| 04487c9b24 | |||
|
|
20eb62d19b | ||
|
|
567d3250e2 | ||
| 4207d5ee50 | |||
|
|
2ec95dd3c3 | ||
|
|
c0d9ee7ca6 | ||
|
|
bf09a25eb5 | ||
|
|
373128ba53 | ||
|
|
3a09d10d3b | ||
|
|
87de52cae0 | ||
|
|
931fe13eee | ||
|
|
28716eeab2 | ||
|
|
27f7945289 | ||
|
|
5a190f3b96 | ||
|
|
606af3901c | ||
|
|
b2247e79ae | ||
|
|
3d76834f45 | ||
| ccc86895e2 | |||
| 279d16d59a | |||
| 04d288b844 | |||
|
|
a6ae770194 | ||
|
|
e378465126 |
@@ -8,6 +8,6 @@ insert_final_newline = true
|
||||
indent_style = tab
|
||||
indent_size = tab
|
||||
|
||||
[{*.yaml,*.yml}]
|
||||
[{*.yaml,*.yml,*.nix}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
16
.env.example
16
.env.example
@@ -7,4 +7,18 @@ JWT_SECRET=wow
|
||||
POSTGRES_DB=chromacase
|
||||
API_URL=http://localhost:80/api
|
||||
SCORO_URL=ws://localhost:6543
|
||||
|
||||
MINIO_ROOT_PASSWORD=12345678
|
||||
EXPO_PUBLIC_API_URL=http://localhost:80/api
|
||||
EXPO_PUBLIC_SCORO_URL=ws://localhost:6543
|
||||
GOOGLE_CLIENT_ID=toto
|
||||
GOOGLE_SECRET=tata
|
||||
GOOGLE_CALLBACK_URL=http://localhost:19006/logged/google
|
||||
SMTP_TRANSPORT=smtps://toto:tata@relay
|
||||
MAIL_AUTHOR='"Chromacase" <chromacase@octohub.app>'
|
||||
IGNORE_MAILS=true
|
||||
API_KEYS=SCOROTEST,ROBOTO,SCORO
|
||||
API_KEY_SCORO_TEST=SCOROTEST
|
||||
API_KEY_ROBOT=ROBOTO
|
||||
API_KEY_SCORO=SCORO
|
||||
MEILI_MASTER_KEY="ghvjkgisbgkbgskegblfqbgjkebbhgwkjfb"
|
||||
# vi: ft=sh
|
||||
|
||||
5
.envrc
5
.envrc
@@ -1,4 +1 @@
|
||||
if ! has nix_direnv_version || ! nix_direnv_version 2.2.1; then
|
||||
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/2.2.1/direnvrc" "sha256-zelF0vLbEl5uaqrfIzbgNzJWGmLzCmYAkInj/LNxvKs="
|
||||
fi
|
||||
use flake
|
||||
use nix
|
||||
|
||||
1
.git-blame-ignore-revs
Normal file
1
.git-blame-ignore-revs
Normal file
@@ -0,0 +1 @@
|
||||
14e241db37c4080bc0bd87363cf7a57ef8379f46
|
||||
152
.github/workflows/CI.yml
vendored
152
.github/workflows/CI.yml
vendored
@@ -1,151 +1,18 @@
|
||||
name: CI
|
||||
|
||||
name: Deploy
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- main
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
- main
|
||||
|
||||
jobs:
|
||||
|
||||
## Build Back ##
|
||||
|
||||
Build_Back:
|
||||
deployment:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./back
|
||||
environment: Staging
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Build Docker
|
||||
run: docker build -t testback .
|
||||
|
||||
## Build App ##
|
||||
|
||||
Build_Front:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./front
|
||||
environment: Staging
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Install Yarn
|
||||
run: npm install -g yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
|
||||
- name: Type Check
|
||||
run: yarn tsc
|
||||
- name: Check Prettier
|
||||
run: yarn pretty:check .
|
||||
- name: Run Linter
|
||||
run: yarn lint
|
||||
|
||||
- name: 🏗 Setup Expo
|
||||
uses: expo/expo-github-action@v7
|
||||
with:
|
||||
expo-version: latest
|
||||
eas-version: 3.3.1
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: Build Android APK
|
||||
run: |
|
||||
eas build -p android --profile production --local --non-interactive
|
||||
mv *.apk chromacase.apk
|
||||
|
||||
- name: Upload Artifact
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: chromacase.apk
|
||||
path: front/
|
||||
|
||||
## Test Backend ##
|
||||
|
||||
Test_Back:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
needs: [ Build_Back ]
|
||||
environment: Staging
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Copy env file to github secret env file
|
||||
run: |
|
||||
touch .env
|
||||
echo "POSTGRES_USER=user" >> .env
|
||||
echo "POSTGRES_PASSWORD=eip" >> .env
|
||||
echo "POSTGRES_NAME=chromacase" >> .env
|
||||
echo "POSTGRES_HOST=db" >> .env
|
||||
echo "DATABASE_URL=postgresql://user:eip@db:5432/chromacase" >> .env
|
||||
echo "JWT_SECRET=wow" >> .env
|
||||
echo "POSTGRES_DB=chromacase" >> .env
|
||||
echo "API_URL=http://localhost:80/api" >> .env
|
||||
|
||||
- name: Start the service
|
||||
run: docker-compose up -d back db
|
||||
|
||||
- name: Perform healthchecks
|
||||
run: |
|
||||
docker-compose ps -a
|
||||
wget --retry-connrefused http://localhost:3000 # /healthcheck
|
||||
|
||||
- name: Run scorometer tests
|
||||
run: |
|
||||
pip install -r scorometer/requirements.txt
|
||||
cd scorometer/tests && ./runner.sh
|
||||
|
||||
- name: Run robot tests
|
||||
run: |
|
||||
pip install -r back/test/robot/requirements.txt
|
||||
robot -d out back/test/robot/
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: results
|
||||
path: out
|
||||
|
||||
- name: Write results to Pull Request and Summary
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
uses: joonvena/robotframework-reporter-action@v2.1
|
||||
with:
|
||||
report_path: out/
|
||||
gh_access_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
only_summary: false
|
||||
|
||||
- name: Write results to Summary
|
||||
if: always() && github.event_name != 'pull_request'
|
||||
uses: joonvena/robotframework-reporter-action@v2.1
|
||||
with:
|
||||
report_path: out/
|
||||
gh_access_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
only_summary: true
|
||||
|
||||
- name: Remove .env && stop the service
|
||||
run: docker-compose down && rm .env
|
||||
|
||||
## Test App ##
|
||||
|
||||
## Deployement ##
|
||||
|
||||
Deployement_Docker:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
needs: [ Test_Back ]
|
||||
environment: Production
|
||||
if: github.event.pull_request.merged == true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
@@ -190,6 +57,7 @@ jobs:
|
||||
build-args: |
|
||||
API_URL=${{secrets.API_URL}}
|
||||
SCORO_URL=${{secrets.SCORO_URL}}
|
||||
|
||||
- name: Docker meta scorometer
|
||||
id: meta_scorometer
|
||||
uses: docker/metadata-action@v4
|
||||
|
||||
98
.github/workflows/back.yml
vendored
Normal file
98
.github/workflows/back.yml
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
name: "Back"
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
# Required permissions
|
||||
permissions:
|
||||
pull-requests: read
|
||||
# Set job outputs to values from filter step
|
||||
outputs:
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
scoro: ${{ steps.filter.outputs.scoro }}
|
||||
steps:
|
||||
# For pull requests it's not necessary to checkout the code
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
backend:
|
||||
- 'backend/**'
|
||||
frontend:
|
||||
- 'frontend/**'
|
||||
scoro:
|
||||
- 'scorometer/**'
|
||||
back_build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.backend == 'true' }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./back
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Build Docker
|
||||
run: docker build -t testback .
|
||||
|
||||
|
||||
back_test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
needs: [ back_build ]
|
||||
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Copy env file to github secret env file
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Build and start the service
|
||||
run: docker-compose up -d meilisearch back db
|
||||
|
||||
- name: Perform healthchecks
|
||||
run: |
|
||||
docker-compose ps -a
|
||||
docker-compose logs
|
||||
wget --retry-connrefused http://localhost:3000 || (docker-compose logs && exit 1)
|
||||
|
||||
- name: Run robot tests
|
||||
run: |
|
||||
export API_KEY_ROBOT=ROBOTO
|
||||
pip install -r back/test/robot/requirements.txt
|
||||
robot -d out back/test/robot/
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: results
|
||||
path: out
|
||||
|
||||
- name: Write results to Pull Request and Summary
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
uses: joonvena/robotframework-reporter-action@v2.1
|
||||
with:
|
||||
report_path: out/
|
||||
gh_access_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
only_summary: false
|
||||
|
||||
- name: Write results to Summary
|
||||
if: always() && github.event_name != 'pull_request'
|
||||
uses: joonvena/robotframework-reporter-action@v2.1
|
||||
with:
|
||||
report_path: out/
|
||||
gh_access_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
only_summary: true
|
||||
|
||||
- name: stop the service
|
||||
run: docker-compose down
|
||||
95
.github/workflows/front.yml
vendored
Normal file
95
.github/workflows/front.yml
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
name: "Front"
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
# Required permissions
|
||||
permissions:
|
||||
pull-requests: read
|
||||
# Set job outputs to values from filter step
|
||||
outputs:
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
scoro: ${{ steps.filter.outputs.scoro }}
|
||||
steps:
|
||||
# For pull requests it's not necessary to checkout the code
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
backend:
|
||||
- 'backend/**'
|
||||
frontend:
|
||||
- 'frontend/**'
|
||||
scoro:
|
||||
- 'scorometer/**'
|
||||
front_check:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./front
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: front/yarn.lock
|
||||
- run: yarn install --frozen-lockfile
|
||||
- name: type check
|
||||
run: yarn tsc
|
||||
- name: prettier
|
||||
run: yarn pretty:check .
|
||||
- name: eslint
|
||||
run: yarn lint
|
||||
|
||||
front_build:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./front
|
||||
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
||||
needs: [ front_check ]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: front/yarn.lock
|
||||
- run: yarn install --frozen-lockfile
|
||||
|
||||
- name: 🏗 Setup Expo
|
||||
uses: expo/expo-github-action@v8
|
||||
with:
|
||||
expo-version: latest
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: Build Web App
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./front
|
||||
push: false
|
||||
tags: ${{steps.meta_front.outputs.tags}}
|
||||
build-args: |
|
||||
API_URL=${{secrets.API_URL}}
|
||||
SCORO_URL=${{secrets.SCORO_URL}}
|
||||
|
||||
- name: Build Android APK
|
||||
run: |
|
||||
eas build -p android --profile production --local --non-interactive
|
||||
mv *.apk chromacase.apk
|
||||
|
||||
- name: Upload Artifact
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: chromacase.apk
|
||||
path: front/
|
||||
60
.github/workflows/scoro.yml
vendored
Normal file
60
.github/workflows/scoro.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
name: "Scoro"
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
# Required permissions
|
||||
permissions:
|
||||
pull-requests: read
|
||||
# Set job outputs to values from filter step
|
||||
outputs:
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
scoro: ${{ steps.filter.outputs.scoro }}
|
||||
steps:
|
||||
# For pull requests it's not necessary to checkout the code
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
backend:
|
||||
- 'backend/**'
|
||||
frontend:
|
||||
- 'frontend/**'
|
||||
scoro:
|
||||
- 'scorometer/**'
|
||||
scoro_test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.scoro == 'true' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Copy env file to github secret env file
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Build and start the service
|
||||
run: docker-compose up -d meilisearch back db
|
||||
|
||||
- name: Perform healthchecks
|
||||
run: |
|
||||
docker-compose ps -a
|
||||
docker-compose logs
|
||||
wget --retry-connrefused http://localhost:3000 || (docker-compose logs && exit 1)
|
||||
|
||||
- name: Run scorometer tests
|
||||
run: |
|
||||
export API_KEY_SCORO_TEST=SCOROTEST
|
||||
export API_KEY_SCORO=SCORO
|
||||
pip install -r scorometer/requirements.txt
|
||||
cd scorometer/tests && ./runner.sh
|
||||
|
||||
- name: stop the service
|
||||
run: docker-compose down
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -13,3 +13,7 @@ log.html
|
||||
node_modules/
|
||||
./front/coverage
|
||||
.venv
|
||||
.data
|
||||
.DS_Store
|
||||
_gen
|
||||
venv
|
||||
|
||||
38
README.md
38
README.md
@@ -1,9 +1,39 @@
|
||||
# 
|
||||
# 
|
||||
|
||||
La principale raison pour laquelle on arrête de jouer d'un instrument est la perte de motivation. C'est un apprentissage long et vraiment demandant. ChromaCase propose d'accompagner les joueurs de piano grâce à une application mobile avec une expérience personnalisée. Celle-ci, générée par une IA, cible les goûts et identifie les difficultés du joueur.
|
||||
|
||||
Ça vous interesse? Rendez-vous sur notre [site](https://chromacase.studio/) pour prendre contact
|
||||
Ça vous interesse? Rendez-vous sur notre [site](http://eip.epitech.eu/2024/chromacase) pour prendre contact
|
||||
|
||||
## Structure du Projet
|
||||
## Comment lancer le projet
|
||||
|
||||

|
||||
Pensez à remplir un `.env` (à la racine du projet), en se basant sur le `.env.example`.
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.dev.yml up --build
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
## Liens Utiles
|
||||
|
||||
- Site de Production: [Lien](http://chroma.octohub.app/)
|
||||
- Site du Nightly: [Lien](http://nightly.chroma.octohub.app/)
|
||||
- Site vitrine: [Lien](http://eip.epitech.eu/2024/chromacase)
|
||||
- Documentation: [Github](https://github.com/Chroma-Case/DAteX)
|
||||
|
||||
## Membres du Projet
|
||||
|
||||
| Nom | Role | Contact |
|
||||
|--------------------------|--------------------------------------|----------------------------------------------------|
|
||||
| Zoé Roux | CEO, Responsable Back-end | [GitHub](https://github.com/zoriya) |
|
||||
| Clément Le-Bihan | CTO, Responsable Front-end | [GitHub](https://github.com/Octopus773) |
|
||||
| Arthur Jamet | Manager, Développeur Front-end | [GitHub](https://github.com/Arthi-chaud) |
|
||||
| Louis Auzuret | Développeur Back-end, Responsable CI | [Github](https://github.com/GitBluub) |
|
||||
| Aumaury Danis-Cousandier | Développeur Front-end | [Github](https://github.com/AmauryDanisCousandier) |
|
||||
| Mathys Paul | Développeur Front-end, Designer | [GitHub](https://github.com/mathysPaul) |
|
||||
|
||||
BIN
assets/graphical/banner.png
Normal file
BIN
assets/graphical/banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 597 KiB |
@@ -1,12 +1,17 @@
|
||||
#!/bin/env python3
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import sys
|
||||
import os
|
||||
import requests
|
||||
import glob
|
||||
from mido import MidiFile
|
||||
from configparser import ConfigParser
|
||||
|
||||
url = os.environ.get("API_URL")
|
||||
api_key = os.environ.get("API_KEY_POPULATE")
|
||||
auth_headers = {}
|
||||
auth_headers["Authorization"] = f"API Key {api_key}"
|
||||
|
||||
|
||||
def getOrCreateAlbum(name, artistId):
|
||||
if not name:
|
||||
@@ -14,27 +19,27 @@ def getOrCreateAlbum(name, artistId):
|
||||
res = requests.post(f"{url}/album", json={
|
||||
"name": name,
|
||||
"artist": artistId,
|
||||
})
|
||||
},headers=auth_headers)
|
||||
out = res.json()
|
||||
print(out)
|
||||
return out["id"]
|
||||
|
||||
def getOrCreateGenre(names):
|
||||
ids = []
|
||||
for name in names.split(","):
|
||||
res = requests.post(f"{url}/genre", json={
|
||||
"name": name,
|
||||
})
|
||||
out = res.json()
|
||||
print(out)
|
||||
ids += [out["id"]]
|
||||
#TODO handle multiple genres
|
||||
return ids[0]
|
||||
ids = []
|
||||
for name in names.split(","):
|
||||
res = requests.post(f"{url}/genre", json={
|
||||
"name": name,
|
||||
},headers=auth_headers)
|
||||
out = res.json()
|
||||
print(out)
|
||||
ids += [out["id"]]
|
||||
#TODO handle multiple genres
|
||||
return ids[0]
|
||||
|
||||
def getOrCreateArtist(name):
|
||||
res = requests.post(f"{url}/artist", json={
|
||||
"name": name,
|
||||
})
|
||||
},headers=auth_headers)
|
||||
out = res.json()
|
||||
print(out)
|
||||
return out["id"]
|
||||
@@ -42,10 +47,13 @@ def getOrCreateArtist(name):
|
||||
def populateFile(path, midi, mxl):
|
||||
config = ConfigParser()
|
||||
config.read(path)
|
||||
mid = MidiFile(midi)
|
||||
metadata = config["Metadata"];
|
||||
difficulties = dict(config["Difficulties"])
|
||||
difficulties["length"] = round((mid.length), 2)
|
||||
artistId = getOrCreateArtist(metadata["Artist"])
|
||||
print(f"Populating {metadata['Name']}")
|
||||
print(auth_headers)
|
||||
res = requests.post(f"{url}/song", json={
|
||||
"name": metadata["Name"],
|
||||
"midiPath": f"/assets/{midi}",
|
||||
@@ -55,7 +63,7 @@ def populateFile(path, midi, mxl):
|
||||
"album": getOrCreateAlbum(metadata["Album"], artistId),
|
||||
"genre": getOrCreateGenre(metadata["Genre"]),
|
||||
"illustrationPath": f"/assets/{os.path.commonpath([midi, mxl])}/illustration.png"
|
||||
})
|
||||
}, headers=auth_headers)
|
||||
print(res.json())
|
||||
|
||||
|
||||
|
||||
2
assets/requirements.txt
Normal file
2
assets/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
mido
|
||||
requests
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -5,4 +5,4 @@ RUN npm install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
CMD npx prisma migrate dev; npm run start:prod
|
||||
CMD npx prisma migrate deploy; npm run start:prod
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
FROM node:17
|
||||
WORKDIR /app
|
||||
CMD npx prisma generate ; npx prisma migrate dev ; npm run start:dev
|
||||
CMD npm i ; npx prisma generate ; npx prisma migrate dev ; npm run start:dev
|
||||
|
||||
18310
back/package-lock.json
generated
18310
back/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,8 +10,8 @@
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:dev": "nest start --watch --preserveWatchOutput",
|
||||
"start:debug": "nest start --debug --watch --preserveWatchOutput",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
@@ -21,57 +21,74 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^8.0.0",
|
||||
"@nestjs/config": "^2.1.0",
|
||||
"@nestjs/core": "^8.0.0",
|
||||
"@nestjs/jwt": "^8.0.1",
|
||||
"@nestjs-modules/mailer": "^1.9.1",
|
||||
"@nestjs/common": "^10.1.0",
|
||||
"@nestjs/config": "^3.0.0",
|
||||
"@nestjs/core": "^10.1.0",
|
||||
"@nestjs/jwt": "^10.1.0",
|
||||
"@nestjs/mapped-types": "*",
|
||||
"@nestjs/passport": "^8.2.2",
|
||||
"@nestjs/platform-express": "^8.0.0",
|
||||
"@nestjs/swagger": "^5.2.1",
|
||||
"@prisma/client": "^4.4.0",
|
||||
"@nestjs/passport": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.1.0",
|
||||
"@nestjs/swagger": "^7.1.2",
|
||||
"@prisma/client": "^5.0.0",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/passport": "^1.0.9",
|
||||
"@types/passport": "^1.0.12",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"canvas": "^2.11.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.13.2",
|
||||
"passport-jwt": "^4.0.0",
|
||||
"class-validator": "^0.14.0",
|
||||
"cross-blob": "^3.0.2",
|
||||
"fs": "^0.0.1-security",
|
||||
"jsdom": "^22.1.0",
|
||||
"json-logger-service": "^9.0.1",
|
||||
"meilisearch": "^0.35.0",
|
||||
"node-fetch": "^2.6.12",
|
||||
"nodemailer": "^6.9.5",
|
||||
"opensheetmusicdisplay": "^1.8.4",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-headerapikey": "^1.2.2",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"prisma-class-generator": "^0.2.7",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.2",
|
||||
"rxjs": "^7.2.0",
|
||||
"swagger-ui-express": "^4.5.0"
|
||||
"rimraf": "^5.0.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"swagger-ui-express": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^8.0.0",
|
||||
"@nestjs/schematics": "^8.0.0",
|
||||
"@nestjs/testing": "^8.0.0",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/jest": "27.4.1",
|
||||
"@types/node": "^16.0.0",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
"eslint": "^8.0.1",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"jest": "^27.2.5",
|
||||
"prettier": "^2.3.2",
|
||||
"prisma": "^4.4.0",
|
||||
"source-map-support": "^0.5.20",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "^27.0.3",
|
||||
"ts-loader": "^9.2.3",
|
||||
"ts-node": "^10.0.0",
|
||||
"tsconfig-paths": "^3.10.1",
|
||||
"typescript": "^4.3.5"
|
||||
"@nestjs/cli": "^10.1.10",
|
||||
"@nestjs/schematics": "^10.0.1",
|
||||
"@nestjs/testing": "^10.1.0",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "29.5.3",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^20.4.4",
|
||||
"@types/nodemailer": "^6.4.9",
|
||||
"@types/passport-google-oauth20": "^2.0.11",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "^6.1.0",
|
||||
"@typescript-eslint/parser": "^6.1.0",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"jest": "^29.6.1",
|
||||
"prettier": "^3.0.0",
|
||||
"prisma": "^5.0.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^6.3.3",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-loader": "^9.4.4",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
"ts",
|
||||
"mjs"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
|
||||
12
back/prisma/migrations/20230621090510_google/migration.sql
Normal file
12
back/prisma/migrations/20230621090510_google/migration.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[googleID]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "googleID" TEXT,
|
||||
ALTER COLUMN "password" DROP NOT NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_googleID_key" ON "User"("googleID");
|
||||
2
back/prisma/migrations/20230907141258_/migration.sql
Normal file
2
back/prisma/migrations/20230907141258_/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "emailVerified" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "LikedSongs" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"songId" INTEGER NOT NULL,
|
||||
"addedDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "LikedSongs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LikedSongs" ADD CONSTRAINT "LikedSongs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LikedSongs" ADD CONSTRAINT "LikedSongs_songId_fkey" FOREIGN KEY ("songId") REFERENCES "Song"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
8
back/prisma/migrations/20230920151856_/migration.sql
Normal file
8
back/prisma/migrations/20230920151856_/migration.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[email]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
2
back/prisma/migrations/20230921103156_/migration.sql
Normal file
2
back/prisma/migrations/20230921103156_/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ALTER COLUMN "email" DROP NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "totalScore" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -4,6 +4,12 @@ generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
generator prismaClassGenerator {
|
||||
provider = "prisma-class-generator"
|
||||
dryRun = false
|
||||
separateRelationFields = true
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
@@ -12,14 +18,27 @@ datasource db {
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
password String
|
||||
email String
|
||||
password String?
|
||||
email String? @unique
|
||||
emailVerified Boolean @default(false)
|
||||
googleID String? @unique
|
||||
isGuest Boolean @default(false)
|
||||
partyPlayed Int @default(0)
|
||||
totalScore Int @default(0)
|
||||
LessonHistory LessonHistory[]
|
||||
SongHistory SongHistory[]
|
||||
searchHistory SearchHistory[]
|
||||
settings UserSettings?
|
||||
likedSongs LikedSongs[]
|
||||
}
|
||||
|
||||
model LikedSongs {
|
||||
id Int @id @default(autoincrement())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int
|
||||
song Song @relation(fields: [songId], references: [id], onDelete: Cascade)
|
||||
songId Int
|
||||
addedDate DateTime @default(now())
|
||||
}
|
||||
|
||||
model UserSettings {
|
||||
@@ -59,6 +78,7 @@ model Song {
|
||||
genre Genre? @relation(fields: [genreId], references: [id])
|
||||
difficulties Json
|
||||
SongHistory SongHistory[]
|
||||
likedByUsers LikedSongs[]
|
||||
}
|
||||
|
||||
model SongHistory {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
ConflictException,
|
||||
Controller,
|
||||
@@ -12,23 +11,36 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { Plage } from 'src/models/plage';
|
||||
import { CreateAlbumDto } from './dto/create-album.dto';
|
||||
import { AlbumService } from './album.service';
|
||||
import { Request } from 'express';
|
||||
import { Prisma, Album } from '@prisma/client';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { FilterQuery } from 'src/utils/filter.pipe';
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOkResponsePlaginated, Plage } from "src/models/plage";
|
||||
import { CreateAlbumDto } from "./dto/create-album.dto";
|
||||
import { AlbumService } from "./album.service";
|
||||
import { Request } from "express";
|
||||
import { Prisma, Album } from "@prisma/client";
|
||||
import { ApiOkResponse, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { FilterQuery } from "src/utils/filter.pipe";
|
||||
import { Album as _Album } from "src/_gen/prisma-class/album";
|
||||
import { IncludeMap, mapInclude } from "src/utils/include";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
import { ChromaAuthGuard } from "src/auth/chroma-auth.guard";
|
||||
|
||||
@Controller('album')
|
||||
@ApiTags('album')
|
||||
@Controller("album")
|
||||
@ApiTags("album")
|
||||
@UseGuards(ChromaAuthGuard)
|
||||
export class AlbumController {
|
||||
static filterableFields: string[] = ['+id', 'name', '+artistId'];
|
||||
static filterableFields: string[] = ["+id", "name", "+artistId"];
|
||||
static includableFields: IncludeMap<Prisma.AlbumInclude> = {
|
||||
artist: true,
|
||||
Song: true,
|
||||
};
|
||||
|
||||
constructor(private readonly albumService: AlbumService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({
|
||||
description: "Register a new album, should not be used by frontend",
|
||||
})
|
||||
async create(@Body() createAlbumDto: CreateAlbumDto) {
|
||||
try {
|
||||
return await this.albumService.createAlbum({
|
||||
@@ -44,36 +56,50 @@ export class AlbumController {
|
||||
}
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@Param('id', ParseIntPipe) id: number) {
|
||||
@Delete(":id")
|
||||
@ApiOperation({ description: "Delete an album by id" })
|
||||
async remove(@Param("id", ParseIntPipe) id: number) {
|
||||
try {
|
||||
return await this.albumService.deleteAlbum({ id });
|
||||
} catch {
|
||||
throw new NotFoundException('Invalid ID');
|
||||
throw new NotFoundException("Invalid ID");
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOkResponsePlaginated(_Album)
|
||||
@ApiOperation({ description: "Get all albums paginated" })
|
||||
async findAll(
|
||||
@Req() req: Request,
|
||||
@FilterQuery(AlbumController.filterableFields)
|
||||
where: Prisma.AlbumWhereInput,
|
||||
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||
@Query("include") include: string,
|
||||
@Query("skip", new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||
@Query("take", new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||
): Promise<Plage<Album>> {
|
||||
const ret = await this.albumService.albums({
|
||||
skip,
|
||||
take,
|
||||
where,
|
||||
include: mapInclude(include, req, AlbumController.includableFields),
|
||||
});
|
||||
return new Plage(ret, req);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
const res = await this.albumService.album({ id });
|
||||
@Get(":id")
|
||||
@ApiOperation({ description: "Get an album by id" })
|
||||
@ApiOkResponse({ type: _Album })
|
||||
async findOne(
|
||||
@Req() req: Request,
|
||||
@Query("include") include: string,
|
||||
@Param("id", ParseIntPipe) id: number,
|
||||
) {
|
||||
const res = await this.albumService.album(
|
||||
{ id },
|
||||
mapInclude(include, req, AlbumController.includableFields),
|
||||
);
|
||||
|
||||
if (res === null) throw new NotFoundException('Album not found');
|
||||
if (res === null) throw new NotFoundException("Album not found");
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
import { AlbumController } from './album.controller';
|
||||
import { AlbumService } from './album.service';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PrismaModule } from "src/prisma/prisma.module";
|
||||
import { AlbumController } from "./album.controller";
|
||||
import { AlbumService } from "./album.service";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma, Album } from '@prisma/client';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma, Album } from "@prisma/client";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class AlbumService {
|
||||
@@ -14,9 +14,11 @@ export class AlbumService {
|
||||
|
||||
async album(
|
||||
albumWhereUniqueInput: Prisma.AlbumWhereUniqueInput,
|
||||
include?: Prisma.AlbumInclude,
|
||||
): Promise<Album | null> {
|
||||
return this.prisma.album.findUnique({
|
||||
where: albumWhereUniqueInput,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,14 +28,16 @@ export class AlbumService {
|
||||
cursor?: Prisma.AlbumWhereUniqueInput;
|
||||
where?: Prisma.AlbumWhereInput;
|
||||
orderBy?: Prisma.AlbumOrderByWithRelationInput;
|
||||
include?: Prisma.AlbumInclude;
|
||||
}): Promise<Album[]> {
|
||||
const { skip, take, cursor, where, orderBy } = params;
|
||||
const { skip, take, cursor, where, orderBy, include } = params;
|
||||
return this.prisma.album.findMany({
|
||||
skip,
|
||||
take,
|
||||
cursor,
|
||||
where,
|
||||
orderBy,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsNotEmpty } from "class-validator";
|
||||
|
||||
export class CreateAlbumDto {
|
||||
@IsNotEmpty()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { AppController } from "./app.controller";
|
||||
import { AppService } from "./app.service";
|
||||
|
||||
describe('AppController', () => {
|
||||
describe("AppController", () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -14,9 +14,9 @@ describe('AppController', () => {
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
describe("root", () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
expect(appController.getHello()).toBe("Hello World!");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { AppService } from "./app.service";
|
||||
import { ApiOkResponse } from "@nestjs/swagger";
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOkResponse({
|
||||
description: "Return a hello world message, used as a health route",
|
||||
})
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { PrismaService } from './prisma/prisma.service';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { SongModule } from './song/song.module';
|
||||
import { LessonModule } from './lesson/lesson.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { ArtistService } from './artist/artist.service';
|
||||
import { GenreModule } from './genre/genre.module';
|
||||
import { ArtistModule } from './artist/artist.module';
|
||||
import { AlbumModule } from './album/album.module';
|
||||
import { SearchModule } from './search/search.module';
|
||||
import { HistoryModule } from './history/history.module';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AppController } from "./app.controller";
|
||||
import { AppService } from "./app.service";
|
||||
import { PrismaService } from "./prisma/prisma.service";
|
||||
import { UsersModule } from "./users/users.module";
|
||||
import { PrismaModule } from "./prisma/prisma.module";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { SongModule } from "./song/song.module";
|
||||
import { LessonModule } from "./lesson/lesson.module";
|
||||
import { SettingsModule } from "./settings/settings.module";
|
||||
import { ArtistService } from "./artist/artist.service";
|
||||
import { GenreModule } from "./genre/genre.module";
|
||||
import { ArtistModule } from "./artist/artist.module";
|
||||
import { AlbumModule } from "./album/album.module";
|
||||
import { SearchModule } from "./search/search.module";
|
||||
import { HistoryModule } from "./history/history.module";
|
||||
import { MailerModule } from "@nestjs-modules/mailer";
|
||||
import { ScoresModule } from "./scores/scores.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -28,6 +30,13 @@ import { HistoryModule } from './history/history.module';
|
||||
SearchModule,
|
||||
SettingsModule,
|
||||
HistoryModule,
|
||||
ScoresModule,
|
||||
MailerModule.forRoot({
|
||||
transport: process.env.SMTP_TRANSPORT,
|
||||
defaults: {
|
||||
from: process.env.MAIL_AUTHOR,
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService, PrismaService, ArtistService],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
return "Hello World!";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
ConflictException,
|
||||
Controller,
|
||||
@@ -14,24 +13,43 @@ import {
|
||||
Query,
|
||||
Req,
|
||||
StreamableFile,
|
||||
} from '@nestjs/common';
|
||||
import { Plage } from 'src/models/plage';
|
||||
import { CreateArtistDto } from './dto/create-artist.dto';
|
||||
import { Request } from 'express';
|
||||
import { ArtistService } from './artist.service';
|
||||
import { Prisma, Artist } from '@prisma/client';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { createReadStream, existsSync } from 'fs';
|
||||
import { FilterQuery } from 'src/utils/filter.pipe';
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOkResponsePlaginated, Plage } from "src/models/plage";
|
||||
import { CreateArtistDto } from "./dto/create-artist.dto";
|
||||
import { Request } from "express";
|
||||
import { ArtistService } from "./artist.service";
|
||||
import { Prisma, Artist } from "@prisma/client";
|
||||
import {
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { createReadStream, existsSync } from "fs";
|
||||
import { FilterQuery } from "src/utils/filter.pipe";
|
||||
import { Artist as _Artist } from "src/_gen/prisma-class/artist";
|
||||
import { IncludeMap, mapInclude } from "src/utils/include";
|
||||
import { Public } from "src/auth/public";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
import { ChromaAuthGuard } from "src/auth/chroma-auth.guard";
|
||||
|
||||
@Controller('artist')
|
||||
@ApiTags('artist')
|
||||
@Controller("artist")
|
||||
@ApiTags("artist")
|
||||
@UseGuards(ChromaAuthGuard)
|
||||
export class ArtistController {
|
||||
static filterableFields = ['+id', 'name'];
|
||||
static filterableFields = ["+id", "name"];
|
||||
static includableFields: IncludeMap<Prisma.ArtistInclude> = {
|
||||
Song: true,
|
||||
Album: true,
|
||||
};
|
||||
|
||||
constructor(private readonly service: ArtistService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({
|
||||
description: "Register a new artist, should not be used by frontend",
|
||||
})
|
||||
async create(@Body() dto: CreateArtistDto) {
|
||||
try {
|
||||
return await this.service.create(dto);
|
||||
@@ -40,22 +58,26 @@ export class ArtistController {
|
||||
}
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@Param('id', ParseIntPipe) id: number) {
|
||||
@Delete(":id")
|
||||
@ApiOperation({ description: "Delete an artist by id" })
|
||||
async remove(@Param("id", ParseIntPipe) id: number) {
|
||||
try {
|
||||
return await this.service.delete({ id });
|
||||
} catch {
|
||||
throw new NotFoundException('Invalid ID');
|
||||
throw new NotFoundException("Invalid ID");
|
||||
}
|
||||
}
|
||||
|
||||
@Get(':id/illustration')
|
||||
async getIllustration(@Param('id', ParseIntPipe) id: number) {
|
||||
@Get(":id/illustration")
|
||||
@ApiOperation({ description: "Get an artist's illustration" })
|
||||
@ApiNotFoundResponse({ description: "Artist or illustration not found" })
|
||||
@Public()
|
||||
async getIllustration(@Param("id", ParseIntPipe) id: number) {
|
||||
const artist = await this.service.get({ id });
|
||||
if (!artist) throw new NotFoundException('Artist not found');
|
||||
if (!artist) throw new NotFoundException("Artist not found");
|
||||
const path = `/assets/artists/${artist.name}/illustration.png`;
|
||||
if (!existsSync(path))
|
||||
throw new NotFoundException('Illustration not found');
|
||||
throw new NotFoundException("Illustration not found");
|
||||
|
||||
try {
|
||||
const file = createReadStream(path);
|
||||
@@ -66,26 +88,39 @@ export class ArtistController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ description: "Get all artists paginated" })
|
||||
@ApiOkResponsePlaginated(_Artist)
|
||||
async findAll(
|
||||
@Req() req: Request,
|
||||
@FilterQuery(ArtistController.filterableFields)
|
||||
where: Prisma.ArtistWhereInput,
|
||||
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||
@Query("include") include: string,
|
||||
@Query("skip", new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||
@Query("take", new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||
): Promise<Plage<Artist>> {
|
||||
const ret = await this.service.list({
|
||||
skip,
|
||||
take,
|
||||
where,
|
||||
include: mapInclude(include, req, ArtistController.includableFields),
|
||||
});
|
||||
return new Plage(ret, req);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
const res = await this.service.get({ id });
|
||||
@Get(":id")
|
||||
@ApiOperation({ description: "Get an artist by id" })
|
||||
@ApiOkResponse({ type: _Artist })
|
||||
async findOne(
|
||||
@Req() req: Request,
|
||||
@Query("include") include: string,
|
||||
@Param("id", ParseIntPipe) id: number,
|
||||
) {
|
||||
const res = await this.service.get(
|
||||
{ id },
|
||||
mapInclude(include, req, ArtistController.includableFields),
|
||||
);
|
||||
|
||||
if (res === null) throw new NotFoundException('Artist not found');
|
||||
if (res === null) throw new NotFoundException("Artist not found");
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
import { ArtistController } from './artist.controller';
|
||||
import { ArtistService } from './artist.service';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PrismaModule } from "src/prisma/prisma.module";
|
||||
import { ArtistController } from "./artist.controller";
|
||||
import { ArtistService } from "./artist.service";
|
||||
import { SearchModule } from "src/search/search.module";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [PrismaModule, SearchModule],
|
||||
controllers: [ArtistController],
|
||||
providers: [ArtistService],
|
||||
})
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma, Artist } from '@prisma/client';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma, Artist } from "@prisma/client";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { MeiliService } from "src/search/meilisearch.service";
|
||||
|
||||
@Injectable()
|
||||
export class ArtistService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private search: MeiliService,
|
||||
) {}
|
||||
|
||||
async create(data: Prisma.ArtistCreateInput): Promise<Artist> {
|
||||
return this.prisma.artist.create({
|
||||
const ret = await this.prisma.artist.create({
|
||||
data,
|
||||
});
|
||||
await this.search.index("artists").addDocuments([ret]);
|
||||
return ret;
|
||||
}
|
||||
|
||||
async get(where: Prisma.ArtistWhereUniqueInput): Promise<Artist | null> {
|
||||
async get(
|
||||
where: Prisma.ArtistWhereUniqueInput,
|
||||
include?: Prisma.ArtistInclude,
|
||||
): Promise<Artist | null> {
|
||||
return this.prisma.artist.findUnique({
|
||||
where,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,20 +34,24 @@ export class ArtistService {
|
||||
cursor?: Prisma.ArtistWhereUniqueInput;
|
||||
where?: Prisma.ArtistWhereInput;
|
||||
orderBy?: Prisma.ArtistOrderByWithRelationInput;
|
||||
include?: Prisma.ArtistInclude;
|
||||
}): Promise<Artist[]> {
|
||||
const { skip, take, cursor, where, orderBy } = params;
|
||||
const { skip, take, cursor, where, orderBy, include } = params;
|
||||
return this.prisma.artist.findMany({
|
||||
skip,
|
||||
take,
|
||||
cursor,
|
||||
where,
|
||||
orderBy,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(where: Prisma.ArtistWhereUniqueInput): Promise<Artist> {
|
||||
return this.prisma.artist.delete({
|
||||
const ret = await this.prisma.artist.delete({
|
||||
where,
|
||||
});
|
||||
await this.search.index("artists").deleteDocument(ret.id);
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsNotEmpty } from "class-validator";
|
||||
|
||||
export class CreateArtistDto {
|
||||
@IsNotEmpty()
|
||||
|
||||
794
back/src/assetsgenerator/generateImages_browserless.js
Normal file
794
back/src/assetsgenerator/generateImages_browserless.js
Normal file
@@ -0,0 +1,794 @@
|
||||
// import Blob from "cross-blob";
|
||||
import FS from "fs";
|
||||
import jsdom from "jsdom";
|
||||
//import headless_gl from "gl"; // this is now imported dynamically in a try catch, in case gl install fails, see #1160
|
||||
import * as OSMD from "opensheetmusicdisplay"; // window needs to be available before we can require OSMD
|
||||
|
||||
let Blob;
|
||||
|
||||
/*
|
||||
Render each OSMD sample, grab the generated images, andg
|
||||
dump them into a local directory as PNG or SVG files.
|
||||
|
||||
inspired by Vexflow's generate_png_images and vexflow-tests.js
|
||||
|
||||
This can be used to generate PNGs or SVGs from OSMD without a browser.
|
||||
It's also used with the visual regression test system (using PNGs) in
|
||||
`tools/visual_regression.sh`
|
||||
(see package.json, used with npm run generate:blessed and generate:current, then test:visual).
|
||||
|
||||
Note: this script needs to "fake" quite a few browser elements, like window, document,
|
||||
and a Canvas HTMLElement (for PNG) or the DOM (for SVG) ,
|
||||
which otherwise are missing in pure nodejs, causing errors in OSMD.
|
||||
For PNG it needs the canvas package installed.
|
||||
There are also some hacks needed to set the container size (offsetWidth) correctly.
|
||||
|
||||
Otherwise you'd need to run a headless browser, which is way slower,
|
||||
see the semi-obsolete generateDiffImagesPuppeteerLocalhost.js
|
||||
*/
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
const timestampToMs = (timestamp, wholeNoteLength) => {
|
||||
return timestamp.RealValue * wholeNoteLength;
|
||||
};
|
||||
const getActualNoteLength = (note, wholeNoteLength) => {
|
||||
let duration = timestampToMs(note.Length, wholeNoteLength);
|
||||
if (note.NoteTie) {
|
||||
const firstNote = note.NoteTie.Notes.at(1);
|
||||
if (Object.is(note.NoteTie.StartNote, note) && firstNote) {
|
||||
duration += timestampToMs(firstNote.Length, wholeNoteLength);
|
||||
} else {
|
||||
duration = 0;
|
||||
}
|
||||
}
|
||||
return duration;
|
||||
};
|
||||
|
||||
function getCursorPositions(osmd, filename, partitionDims) {
|
||||
osmd.cursor.show();
|
||||
const bpm = osmd.Sheet.HasBPMInfo
|
||||
? osmd.Sheet.getExpressionsStartTempoInBPM()
|
||||
: 60;
|
||||
const wholeNoteLength = Math.round((60 / bpm) * 4000);
|
||||
const curPos = [];
|
||||
while (!osmd.cursor.iterator.EndReached) {
|
||||
const notesToPlay = osmd.cursor
|
||||
.NotesUnderCursor()
|
||||
.filter((note) => {
|
||||
return note.isRest() == false && note.Pitch;
|
||||
})
|
||||
.map((note) => {
|
||||
const fixedKey =
|
||||
note.ParentVoiceEntry.ParentVoice.Parent.SubInstruments.at(0)
|
||||
?.fixedKey ?? 0;
|
||||
const midiNumber = note.halfTone - fixedKey * 12;
|
||||
const gain = note.ParentVoiceEntry.ParentVoice.Volume;
|
||||
return {
|
||||
note: midiNumber,
|
||||
gain: gain,
|
||||
duration: getActualNoteLength(note, wholeNoteLength),
|
||||
};
|
||||
});
|
||||
const shortestNotes = osmd.cursor
|
||||
.NotesUnderCursor()
|
||||
.sort((n1, n2) => n1.Length.CompareTo(n2.Length))
|
||||
.at(0);
|
||||
const ts = timestampToMs(
|
||||
shortestNotes?.getAbsoluteTimestamp() ?? new OSMD.Fraction(-1),
|
||||
wholeNoteLength,
|
||||
);
|
||||
const sNL = timestampToMs(
|
||||
shortestNotes?.Length ?? new OSMD.Fraction(-1),
|
||||
wholeNoteLength,
|
||||
);
|
||||
curPos.push({
|
||||
x: parseFloat(osmd.cursor.cursorElement.style.left),
|
||||
y: parseFloat(osmd.cursor.cursorElement.style.top),
|
||||
width: osmd.cursor.cursorElement.width,
|
||||
height: osmd.cursor.cursorElement.height,
|
||||
notes: notesToPlay,
|
||||
timestamp: ts,
|
||||
timing: sNL,
|
||||
});
|
||||
osmd.cursor.next();
|
||||
}
|
||||
osmd.cursor.reset();
|
||||
osmd.cursor.hide();
|
||||
|
||||
const cursorsFilename = `${imageDir}/${filename}.json`;
|
||||
FS.writeFileSync(
|
||||
cursorsFilename,
|
||||
JSON.stringify({
|
||||
pageWidth: partitionDims[0],
|
||||
pageHeight: partitionDims[1],
|
||||
cursors: curPos,
|
||||
}),
|
||||
);
|
||||
console.log(`Saved cursor positions to ${cursorsFilename}`);
|
||||
}
|
||||
|
||||
// global variables
|
||||
// (without these being global, we'd have to pass many of these values to the generateSampleImage function)
|
||||
// eslint-disable-next-line prefer-const
|
||||
let assetName;
|
||||
let sampleDir;
|
||||
let imageDir;
|
||||
let imageFormat;
|
||||
let pageWidth;
|
||||
let pageHeight;
|
||||
let filterRegex;
|
||||
let mode;
|
||||
let debugSleepTimeString;
|
||||
let skyBottomLinePreference;
|
||||
let pageFormat;
|
||||
|
||||
export async function generateSongAssets(
|
||||
assetName_,
|
||||
sampleDir_,
|
||||
imageDir_,
|
||||
imageFormat_,
|
||||
pageWidth_,
|
||||
pageHeight_,
|
||||
filterRegex_,
|
||||
mode_,
|
||||
debugSleepTimeString_,
|
||||
skyBottomLinePreference_,
|
||||
) {
|
||||
assetName = assetName_;
|
||||
sampleDir = sampleDir_;
|
||||
imageDir = imageDir_;
|
||||
imageFormat = imageFormat_;
|
||||
pageWidth = pageWidth_;
|
||||
pageHeight = pageHeight_;
|
||||
filterRegex = filterRegex_;
|
||||
mode = mode_;
|
||||
debugSleepTimeString = debugSleepTimeString_;
|
||||
skyBottomLinePreference = skyBottomLinePreference_;
|
||||
imageFormat = imageFormat?.toLowerCase();
|
||||
eval(`import("cross-blob")`).then((module) => {
|
||||
Blob = module.default;
|
||||
});
|
||||
debug("" + sampleDir + " " + imageDir + " " + imageFormat);
|
||||
|
||||
if (!mode) {
|
||||
mode = "";
|
||||
}
|
||||
if (
|
||||
!assetName ||
|
||||
!sampleDir ||
|
||||
!imageDir ||
|
||||
(imageFormat !== "png" && imageFormat !== "svg")
|
||||
) {
|
||||
console.log(
|
||||
"usage: " +
|
||||
// eslint-disable-next-line max-len
|
||||
"node test/Util/generateImages_browserless.mjs osmdBuildDir sampleDirectory imageDirectory svg|png [width|0] [height|0] [filterRegex|all|allSmall] [--debug|--osmdtesting] [debugSleepTime]",
|
||||
);
|
||||
console.log(
|
||||
" (use pageWidth and pageHeight 0 to not divide the rendering into pages (endless page))",
|
||||
);
|
||||
console.log(
|
||||
' (use "all" to skip filterRegex parameter. "allSmall" with --osmdtesting skips two huge OSMD samples that take forever to render)',
|
||||
);
|
||||
console.log(
|
||||
"example: node test/Util/generateImages_browserless.mjs ../../build ./test/data/ ./export png",
|
||||
);
|
||||
console.log(
|
||||
"Error: need osmdBuildDir, sampleDir, imageDir and svg|png arguments. Exiting.",
|
||||
);
|
||||
Promise.reject(
|
||||
"Error: need osmdBuildDir, sampleDir, imageDir and svg|png arguments. Exiting.",
|
||||
);
|
||||
}
|
||||
await init();
|
||||
}
|
||||
|
||||
// let OSMD; // can only be required once window was simulated
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
|
||||
async function init() {
|
||||
debug("init");
|
||||
|
||||
const osmdTestingMode = mode.includes("osmdtesting"); // can also be --debugosmdtesting
|
||||
const osmdTestingSingleMode = mode.includes("osmdtestingsingle");
|
||||
const DEBUG = mode.startsWith("--debug");
|
||||
// const debugSleepTime = Number.parseInt(process.env.GENERATE_DEBUG_SLEEP_TIME) || 0; // 5000 works for me [sschmidTU]
|
||||
if (DEBUG) {
|
||||
// debug(' (note that --debug slows down the script by about 0.3s per file, through logging)')
|
||||
const debugSleepTimeMs = Number.parseInt(debugSleepTimeString, 10);
|
||||
if (debugSleepTimeMs > 0) {
|
||||
debug("debug sleep time: " + debugSleepTimeString);
|
||||
await sleep(Number.parseInt(debugSleepTimeMs, 10));
|
||||
// [VSCode] apparently this is necessary for the debugger to attach itself in time before the program closes.
|
||||
// sometimes this is not enough, so you may have to try multiple times or increase the sleep timer. Unfortunately debugging nodejs isn't easy.
|
||||
}
|
||||
}
|
||||
debug("sampleDir: " + sampleDir, DEBUG);
|
||||
debug("imageDir: " + imageDir, DEBUG);
|
||||
debug("imageFormat: " + imageFormat, DEBUG);
|
||||
|
||||
pageFormat = "Endless";
|
||||
pageWidth = Number.parseInt(pageWidth, 10);
|
||||
pageHeight = Number.parseInt(pageHeight, 10);
|
||||
const endlessPage = !(pageHeight > 0 && pageWidth > 0);
|
||||
if (!endlessPage) {
|
||||
pageFormat = `${pageWidth}x${pageHeight}`;
|
||||
}
|
||||
|
||||
// ---- hacks to fake Browser elements OSMD and Vexflow need, like window, document, and a canvas HTMLElement ----
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const dom = new jsdom.JSDOM("<!DOCTYPE html></html>");
|
||||
// eslint-disable-next-line no-global-assign
|
||||
// window = dom.window;
|
||||
// eslint-disable-next-line no-global-assign
|
||||
// document = dom.window.document;
|
||||
|
||||
// eslint-disable-next-line no-global-assign
|
||||
global.window = dom.window;
|
||||
// eslint-disable-next-line no-global-assign
|
||||
global.document = window.document;
|
||||
//window.console = console; // probably does nothing
|
||||
global.HTMLElement = window.HTMLElement;
|
||||
global.HTMLAnchorElement = window.HTMLAnchorElement;
|
||||
global.XMLHttpRequest = window.XMLHttpRequest;
|
||||
global.DOMParser = window.DOMParser;
|
||||
global.Node = window.Node;
|
||||
if (imageFormat === "png") {
|
||||
global.Canvas = window.Canvas;
|
||||
}
|
||||
|
||||
// For WebGLSkyBottomLineCalculatorBackend: Try to import gl dynamically
|
||||
// this is so that the script doesn't fail if gl could not be installed,
|
||||
// which can happen in some linux setups where gcc-11 is installed, see #1160
|
||||
try {
|
||||
const { default: headless_gl } = await import("gl");
|
||||
const oldCreateElement = document.createElement.bind(document);
|
||||
document.createElement = function (tagName, options) {
|
||||
if (tagName.toLowerCase() === "canvas") {
|
||||
const canvas = oldCreateElement(tagName, options);
|
||||
const oldGetContext = canvas.getContext.bind(canvas);
|
||||
canvas.getContext = function (contextType, contextAttributes) {
|
||||
if (
|
||||
contextType.toLowerCase() === "webgl" ||
|
||||
contextType.toLowerCase() === "experimental-webgl"
|
||||
) {
|
||||
const gl = headless_gl(
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
contextAttributes,
|
||||
);
|
||||
gl.canvas = canvas;
|
||||
return gl;
|
||||
} else {
|
||||
return oldGetContext(contextType, contextAttributes);
|
||||
}
|
||||
};
|
||||
return canvas;
|
||||
} else {
|
||||
return oldCreateElement(tagName, options);
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
if (skyBottomLinePreference === "--webgl") {
|
||||
debug(
|
||||
"WebGL image generation was requested but gl is not installed; using non-WebGL generation.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// fix Blob not found (to support external modules like is-blob)
|
||||
global.Blob = Blob;
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.id = "browserlessDiv";
|
||||
document.body.appendChild(div);
|
||||
// const canvas = document.createElement('canvas')
|
||||
// div.canvas = document.createElement('canvas')
|
||||
|
||||
const zoom = 1.0;
|
||||
// width of the div / PNG generated
|
||||
let width = pageWidth * zoom;
|
||||
// TODO sometimes the width is way too small for the score, may need to adjust zoom.
|
||||
if (endlessPage) {
|
||||
width = 1440;
|
||||
}
|
||||
let height = pageHeight;
|
||||
if (endlessPage) {
|
||||
height = 32767;
|
||||
}
|
||||
div.width = width;
|
||||
div.height = height;
|
||||
// div.offsetWidth = width; // doesn't work, offsetWidth is always 0 from this. see below
|
||||
// div.clientWidth = width;
|
||||
// div.clientHeight = height;
|
||||
// div.scrollHeight = height;
|
||||
// div.scrollWidth = width;
|
||||
div.setAttribute("width", width);
|
||||
div.setAttribute("height", height);
|
||||
div.setAttribute("offsetWidth", width);
|
||||
// debug('div.offsetWidth: ' + div.offsetWidth, DEBUG) // 0 here, set correctly later
|
||||
// debug('div.height: ' + div.height, DEBUG)
|
||||
|
||||
// hack: set offsetWidth reliably
|
||||
Object.defineProperties(window.HTMLElement.prototype, {
|
||||
offsetLeft: {
|
||||
get: function () {
|
||||
return parseFloat(window.getComputedStyle(this).marginTop) || 0;
|
||||
},
|
||||
},
|
||||
offsetTop: {
|
||||
get: function () {
|
||||
return parseFloat(window.getComputedStyle(this).marginTop) || 0;
|
||||
},
|
||||
},
|
||||
offsetHeight: {
|
||||
get: function () {
|
||||
return height;
|
||||
},
|
||||
},
|
||||
offsetWidth: {
|
||||
get: function () {
|
||||
return width;
|
||||
},
|
||||
},
|
||||
});
|
||||
debug("div.offsetWidth: " + div.offsetWidth, DEBUG);
|
||||
debug("div.height: " + div.height, DEBUG);
|
||||
// ---- end browser hacks (hopefully) ----
|
||||
|
||||
// load globally
|
||||
|
||||
// Create the image directory if it doesn't exist.
|
||||
FS.mkdirSync(imageDir, { recursive: true });
|
||||
|
||||
// const sampleDirFilenames = FS.readdirSync(sampleDir);
|
||||
let samplesToProcess = []; // samples we want to process/generate pngs of, excluding the filtered out files/filenames
|
||||
|
||||
// sampleDir is the direct path to a single file but is then only keept as a the directory containing the file
|
||||
if (sampleDir.match("^.*(.xml)|(.musicxml)|(.mxl)$")) {
|
||||
let pathParts = sampleDir.split("/");
|
||||
let filename = pathParts[pathParts.length - 1];
|
||||
sampleDir = pathParts.slice(0, pathParts.length - 1).join("/");
|
||||
samplesToProcess.push(filename);
|
||||
} else {
|
||||
debug("not a correct extension sampleDir: " + sampleDir, DEBUG);
|
||||
}
|
||||
// for (const sampleFilename of sampleDirFilenames) {
|
||||
// if (osmdTestingMode && filterRegex === "allSmall") {
|
||||
// if (sampleFilename.match("^(Actor)|(Gounod)")) {
|
||||
// // TODO maybe filter by file size instead
|
||||
// debug("filtering big file: " + sampleFilename, DEBUG);
|
||||
// continue;
|
||||
// }
|
||||
// }
|
||||
// // eslint-disable-next-line no-useless-escape
|
||||
// if (sampleFilename.match("^.*(.xml)|(.musicxml)|(.mxl)$")) {
|
||||
// // debug('found musicxml/mxl: ' + sampleFilename)
|
||||
// samplesToProcess.push(sampleFilename);
|
||||
// } else {
|
||||
// debug("discarded file/directory: " + sampleFilename, DEBUG);
|
||||
// }
|
||||
// }
|
||||
|
||||
// filter samples to process by regex if given
|
||||
if (
|
||||
filterRegex &&
|
||||
filterRegex !== "" &&
|
||||
filterRegex !== "all" &&
|
||||
!(osmdTestingMode && filterRegex === "allSmall")
|
||||
) {
|
||||
debug("filtering samples for regex: " + filterRegex, DEBUG);
|
||||
samplesToProcess = samplesToProcess.filter((filename) =>
|
||||
filename.match(filterRegex),
|
||||
);
|
||||
debug(`found ${samplesToProcess.length} matches: `, DEBUG);
|
||||
for (let i = 0; i < samplesToProcess.length; i++) {
|
||||
debug(samplesToProcess[i], DEBUG);
|
||||
}
|
||||
}
|
||||
|
||||
const backend = imageFormat === "png" ? "canvas" : "svg";
|
||||
const osmdInstance = new OSMD.OpenSheetMusicDisplay(div, {
|
||||
autoResize: false,
|
||||
backend: backend,
|
||||
pageBackgroundColor: "#FFFFFF",
|
||||
pageFormat: pageFormat,
|
||||
// defaultFontFamily: 'Arial',
|
||||
drawTitle: false,
|
||||
renderSingleHorizontalStaffline: true,
|
||||
drawComposer: false,
|
||||
drawCredits: false,
|
||||
drawLyrics: false,
|
||||
drawPartNames: false,
|
||||
followCursor: false,
|
||||
cursorsOptions: [{ type: 0, color: "green", alpha: 0.5, follow: false }],
|
||||
});
|
||||
// for more options check OSMDOptions.ts
|
||||
|
||||
// you can set finer-grained rendering/engraving settings in EngravingRules:
|
||||
// osmdInstance.EngravingRules.TitleTopDistance = 5.0 // 5.0 is default
|
||||
// (unless in osmdTestingMode, these will be reset with drawingParameters default)
|
||||
// osmdInstance.EngravingRules.PageTopMargin = 5.0 // 5 is default
|
||||
// osmdInstance.EngravingRules.PageBottomMargin = 5.0 // 5 is default. <5 can cut off scores that extend in the last staffline
|
||||
// note that for now the png and canvas will still have the height given in the script argument,
|
||||
// so even with a margin of 0 the image will be filled to the full height.
|
||||
// osmdInstance.EngravingRules.PageLeftMargin = 5.0 // 5 is default
|
||||
// osmdInstance.EngravingRules.PageRightMargin = 5.0 // 5 is default
|
||||
// osmdInstance.EngravingRules.MetronomeMarkXShift = -8; // -6 is default
|
||||
// osmdInstance.EngravingRules.DistanceBetweenVerticalSystemLines = 0.15; // 0.35 is default
|
||||
// for more options check EngravingRules.ts (though not all of these are meant and fully supported to be changed at will)
|
||||
|
||||
if (DEBUG) {
|
||||
osmdInstance.setLogLevel("debug");
|
||||
// debug(`osmd PageFormat: ${osmdInstance.EngravingRules.PageFormat.width}x${osmdInstance.EngravingRules.PageFormat.height}`)
|
||||
debug(
|
||||
`osmd PageFormat idString: ${osmdInstance.EngravingRules.PageFormat.idString}`,
|
||||
);
|
||||
debug("PageHeight: " + osmdInstance.EngravingRules.PageHeight);
|
||||
} else {
|
||||
osmdInstance.setLogLevel("info"); // doesn't seem to work, log.debug still logs
|
||||
}
|
||||
|
||||
debug(
|
||||
"[OSMD.generateImages] starting loop over samples, saving images to " +
|
||||
imageDir,
|
||||
DEBUG,
|
||||
);
|
||||
for (let i = 0; i < samplesToProcess.length; i++) {
|
||||
const sampleFilename = samplesToProcess[i];
|
||||
debug("sampleFilename: " + sampleFilename, DEBUG);
|
||||
|
||||
await generateSampleImage(
|
||||
sampleFilename,
|
||||
sampleDir,
|
||||
osmdInstance,
|
||||
osmdTestingMode,
|
||||
{},
|
||||
DEBUG,
|
||||
);
|
||||
|
||||
if (
|
||||
osmdTestingMode &&
|
||||
!osmdTestingSingleMode &&
|
||||
sampleFilename.startsWith("Beethoven") &&
|
||||
sampleFilename.includes("Geliebte")
|
||||
) {
|
||||
// generate one more testing image with skyline and bottomline. (startsWith 'Beethoven' don't catch the function test)
|
||||
await generateSampleImage(
|
||||
sampleFilename,
|
||||
sampleDir,
|
||||
osmdInstance,
|
||||
osmdTestingMode,
|
||||
{ skyBottomLine: true },
|
||||
DEBUG,
|
||||
);
|
||||
// generate one more testing image with GraphicalNote positions
|
||||
await generateSampleImage(
|
||||
sampleFilename,
|
||||
sampleDir,
|
||||
osmdInstance,
|
||||
osmdTestingMode,
|
||||
{ boundingBoxes: "VexFlowGraphicalNote" },
|
||||
DEBUG,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
debug("done, exiting.");
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
// let maxRss = 0, maxRssFilename = '' // to log memory usage (debug)
|
||||
async function generateSampleImage(
|
||||
sampleFilename,
|
||||
directory,
|
||||
osmdInstance,
|
||||
osmdTestingMode,
|
||||
options = {},
|
||||
DEBUG = false,
|
||||
) {
|
||||
function makeSkyBottomLineOptions() {
|
||||
const preference = skyBottomLinePreference ?? "";
|
||||
if (preference === "--batch") {
|
||||
return {
|
||||
preferredSkyBottomLineBatchCalculatorBackend: 0, // plain
|
||||
skyBottomLineBatchCriteria: 0, // use batch algorithm only
|
||||
};
|
||||
} else if (preference === "--webgl") {
|
||||
return {
|
||||
preferredSkyBottomLineBatchCalculatorBackend: 1, // webgl
|
||||
skyBottomLineBatchCriteria: 0, // use batch algorithm only
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
preferredSkyBottomLineBatchCalculatorBackend: 0, // plain
|
||||
skyBottomLineBatchCriteria: Infinity, // use non-batch algorithm only
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const samplePath = directory + "/" + sampleFilename;
|
||||
let loadParameter = FS.readFileSync(samplePath);
|
||||
|
||||
if (sampleFilename.endsWith(".mxl")) {
|
||||
loadParameter = await OSMD.MXLHelper.MXLtoXMLstring(loadParameter);
|
||||
} else {
|
||||
loadParameter = loadParameter.toString();
|
||||
}
|
||||
// debug('loadParameter: ' + loadParameter)
|
||||
// debug('typeof loadParameter: ' + typeof loadParameter)
|
||||
|
||||
// set sample-specific options for OSMD visual regression testing
|
||||
let includeSkyBottomLine = false;
|
||||
let drawBoundingBoxString;
|
||||
let isTestOctaveShiftInvisibleInstrument;
|
||||
let isTestInvisibleMeasureNotAffectingLayout;
|
||||
if (osmdTestingMode) {
|
||||
const isFunctionTestAutobeam = sampleFilename.startsWith(
|
||||
"OSMD_function_test_autobeam",
|
||||
);
|
||||
const isFunctionTestAutoColoring = sampleFilename.startsWith(
|
||||
"OSMD_function_test_auto-custom-coloring",
|
||||
);
|
||||
const isFunctionTestSystemAndPageBreaks = sampleFilename.startsWith(
|
||||
"OSMD_Function_Test_System_and_Page_Breaks",
|
||||
);
|
||||
const isFunctionTestDrawingRange = sampleFilename.startsWith(
|
||||
"OSMD_function_test_measuresToDraw_",
|
||||
);
|
||||
const defaultOrCompactTightMode = sampleFilename.startsWith(
|
||||
"OSMD_Function_Test_Container_height",
|
||||
)
|
||||
? "compacttight"
|
||||
: "default";
|
||||
const isTestFlatBeams = sampleFilename.startsWith("test_drum_tuplet_beams");
|
||||
const isTestEndClefStaffEntryBboxes = sampleFilename.startsWith(
|
||||
"test_end_measure_clefs_staffentry_bbox",
|
||||
);
|
||||
const isTestPageBreakImpliesSystemBreak = sampleFilename.startsWith(
|
||||
"test_pagebreak_implies_systembreak",
|
||||
);
|
||||
const isTestPageBottomMargin0 =
|
||||
sampleFilename.includes("PageBottomMargin0");
|
||||
const isTestTupletBracketTupletNumber = sampleFilename.includes(
|
||||
"test_tuplet_bracket_tuplet_number",
|
||||
);
|
||||
const isTestCajon2NoteSystem = sampleFilename.includes(
|
||||
"test_cajon_2-note-system",
|
||||
);
|
||||
isTestOctaveShiftInvisibleInstrument = sampleFilename.includes(
|
||||
"test_octaveshift_first_instrument_invisible",
|
||||
);
|
||||
const isTextOctaveShiftExtraGraphicalMeasure = sampleFilename.includes(
|
||||
"test_octaveshift_extragraphicalmeasure",
|
||||
);
|
||||
isTestInvisibleMeasureNotAffectingLayout = sampleFilename.includes(
|
||||
"test_invisible_measure_not_affecting_layout",
|
||||
);
|
||||
const isTestWedgeMultilineCrescendo = sampleFilename.includes(
|
||||
"test_wedge_multiline_crescendo",
|
||||
);
|
||||
const isTestWedgeMultilineDecrescendo = sampleFilename.includes(
|
||||
"test_wedge_multiline_decrescendo",
|
||||
);
|
||||
osmdInstance.EngravingRules.loadDefaultValues(); // note this may also be executed in setOptions below via drawingParameters default
|
||||
if (isTestEndClefStaffEntryBboxes) {
|
||||
drawBoundingBoxString = "VexFlowStaffEntry";
|
||||
} else {
|
||||
drawBoundingBoxString = options.boundingBoxes; // undefined is also a valid value: no bboxes
|
||||
}
|
||||
osmdInstance.setOptions({
|
||||
autoBeam: isFunctionTestAutobeam, // only set to true for function test autobeam
|
||||
coloringMode: isFunctionTestAutoColoring ? 2 : 0,
|
||||
// eslint-disable-next-line max-len
|
||||
coloringSetCustom: isFunctionTestAutoColoring
|
||||
? [
|
||||
"#d82c6b",
|
||||
"#F89D15",
|
||||
"#FFE21A",
|
||||
"#4dbd5c",
|
||||
"#009D96",
|
||||
"#43469d",
|
||||
"#76429c",
|
||||
"#ff0000",
|
||||
]
|
||||
: undefined,
|
||||
colorStemsLikeNoteheads: isFunctionTestAutoColoring,
|
||||
drawingParameters: defaultOrCompactTightMode, // note: default resets all EngravingRules. could be solved differently
|
||||
drawFromMeasureNumber: isFunctionTestDrawingRange ? 9 : 1,
|
||||
drawUpToMeasureNumber: isFunctionTestDrawingRange
|
||||
? 12
|
||||
: Number.MAX_SAFE_INTEGER,
|
||||
newSystemFromXML: isFunctionTestSystemAndPageBreaks,
|
||||
newSystemFromNewPageInXML: isTestPageBreakImpliesSystemBreak,
|
||||
newPageFromXML: isFunctionTestSystemAndPageBreaks,
|
||||
pageBackgroundColor: "#FFFFFF", // reset by drawingparameters default
|
||||
pageFormat: pageFormat, // reset by drawingparameters default,
|
||||
...makeSkyBottomLineOptions(),
|
||||
});
|
||||
// note that loadDefaultValues() may be executed in setOptions with drawingParameters default
|
||||
//osmdInstance.EngravingRules.RenderSingleHorizontalStaffline = true; // to use this option here, place it after setOptions(), see above
|
||||
osmdInstance.EngravingRules.AlwaysSetPreferredSkyBottomLineBackendAutomatically = false; // this would override the command line options (--plain etc)
|
||||
includeSkyBottomLine = options.skyBottomLine
|
||||
? options.skyBottomLine
|
||||
: false; // apparently es6 doesn't have ?? operator
|
||||
osmdInstance.drawSkyLine = includeSkyBottomLine; // if includeSkyBottomLine, draw skyline and bottomline, else not
|
||||
osmdInstance.drawBottomLine = includeSkyBottomLine;
|
||||
osmdInstance.setDrawBoundingBox(drawBoundingBoxString, false); // false: don't render (now). also (re-)set if undefined!
|
||||
if (isTestFlatBeams) {
|
||||
osmdInstance.EngravingRules.FlatBeams = true;
|
||||
// osmdInstance.EngravingRules.FlatBeamOffset = 30;
|
||||
osmdInstance.EngravingRules.FlatBeamOffset = 10;
|
||||
osmdInstance.EngravingRules.FlatBeamOffsetPerBeam = 10;
|
||||
} else {
|
||||
osmdInstance.EngravingRules.FlatBeams = false;
|
||||
}
|
||||
if (isTestPageBottomMargin0) {
|
||||
osmdInstance.EngravingRules.PageBottomMargin = 0;
|
||||
}
|
||||
if (isTestTupletBracketTupletNumber) {
|
||||
osmdInstance.EngravingRules.TupletNumberLimitConsecutiveRepetitions = true;
|
||||
osmdInstance.EngravingRules.TupletNumberMaxConsecutiveRepetitions = 2;
|
||||
osmdInstance.EngravingRules.TupletNumberAlwaysDisableAfterFirstMax = true; // necessary to trigger bug
|
||||
}
|
||||
if (isTestCajon2NoteSystem) {
|
||||
osmdInstance.EngravingRules.PercussionUseCajon2NoteSystem = true;
|
||||
}
|
||||
if (
|
||||
isTextOctaveShiftExtraGraphicalMeasure ||
|
||||
isTestOctaveShiftInvisibleInstrument ||
|
||||
isTestWedgeMultilineCrescendo ||
|
||||
isTestWedgeMultilineDecrescendo
|
||||
) {
|
||||
osmdInstance.EngravingRules.NewSystemAtXMLNewSystemAttribute = true;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
debug("loading sample " + sampleFilename, DEBUG);
|
||||
await osmdInstance.load(loadParameter, sampleFilename); // if using load.then() without await, memory will not be freed up between renders
|
||||
if (isTestOctaveShiftInvisibleInstrument) {
|
||||
osmdInstance.Sheet.Instruments[0].Visible = false;
|
||||
}
|
||||
if (isTestInvisibleMeasureNotAffectingLayout) {
|
||||
if (osmdInstance.Sheet.Instruments[1]) {
|
||||
// some systems can't handle ?. in this script (just a safety check anyways)
|
||||
osmdInstance.Sheet.Instruments[1].Visible = false;
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
debug(
|
||||
"couldn't load sample " + sampleFilename + ", skipping. Error: \n" + ex,
|
||||
);
|
||||
return Promise.reject(ex);
|
||||
}
|
||||
debug("xml loaded", DEBUG);
|
||||
try {
|
||||
osmdInstance.render();
|
||||
// there were reports that await could help here, but render isn't a synchronous function, and it seems to work. see #932
|
||||
} catch (ex) {
|
||||
debug("renderError: " + ex);
|
||||
}
|
||||
debug("rendered", DEBUG);
|
||||
|
||||
const markupStrings = []; // svg
|
||||
const dataUrls = []; // png
|
||||
let canvasImage;
|
||||
|
||||
// intended to use only for the chromacase partition use case (always 1 page in svg)
|
||||
let partitionDims = [-1, -1];
|
||||
|
||||
for (
|
||||
let pageNumber = 1;
|
||||
pageNumber < Number.POSITIVE_INFINITY;
|
||||
pageNumber++
|
||||
) {
|
||||
if (imageFormat === "png") {
|
||||
canvasImage = document.getElementById(
|
||||
"osmdCanvasVexFlowBackendCanvas" + pageNumber,
|
||||
);
|
||||
if (!canvasImage) {
|
||||
break;
|
||||
}
|
||||
if (!canvasImage.toDataURL) {
|
||||
debug(
|
||||
`error: could not get canvas image for page ${pageNumber} for file: ${sampleFilename}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
dataUrls.push(canvasImage.toDataURL());
|
||||
} else if (imageFormat === "svg") {
|
||||
const svgElement = document.getElementById("osmdSvgPage" + pageNumber);
|
||||
if (!svgElement) {
|
||||
break;
|
||||
}
|
||||
// The important xmlns attribute is not serialized unless we set it here
|
||||
svgElement.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
||||
const width = svgElement.getAttribute("width");
|
||||
const height = svgElement.getAttribute("height");
|
||||
partitionDims = [width, height];
|
||||
markupStrings.push(svgElement.outerHTML);
|
||||
}
|
||||
}
|
||||
|
||||
// create the cursor positions file
|
||||
getCursorPositions(osmdInstance, assetName, partitionDims);
|
||||
|
||||
for (
|
||||
let pageIndex = 0;
|
||||
pageIndex < Math.max(dataUrls.length, markupStrings.length);
|
||||
pageIndex++
|
||||
) {
|
||||
const pageNumberingString = `${pageIndex + 1}`;
|
||||
const skybottomlineString = includeSkyBottomLine ? "skybottomline_" : "";
|
||||
const graphicalNoteBboxesString = drawBoundingBoxString
|
||||
? "bbox" + drawBoundingBoxString + "_"
|
||||
: "";
|
||||
// pageNumberingString = dataUrls.length > 0 ? pageNumberingString : '' // don't put '_1' at the end if only one page. though that may cause more work
|
||||
const pageFilename = `${imageDir}/${assetName}.${imageFormat}`;
|
||||
|
||||
if (imageFormat === "png") {
|
||||
const dataUrl = dataUrls[pageIndex];
|
||||
if (!dataUrl || !dataUrl.split) {
|
||||
debug(
|
||||
`error: could not get dataUrl (imageData) for page ${
|
||||
pageIndex + 1
|
||||
} of sample: ${sampleFilename}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const imageData = dataUrl.split(";base64,").pop();
|
||||
const imageBuffer = Buffer.from(imageData, "base64");
|
||||
|
||||
debug("got image data, saving to: " + pageFilename, DEBUG);
|
||||
FS.writeFileSync(pageFilename, imageBuffer, { encoding: "base64" });
|
||||
} else if (imageFormat === "svg") {
|
||||
const markup = markupStrings[pageIndex];
|
||||
if (!markup) {
|
||||
debug(
|
||||
`error: could not get markup (SVG data) for page ${
|
||||
pageIndex + 1
|
||||
} of sample: ${sampleFilename}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
debug("got svg markup data, saving to: " + pageFilename, DEBUG);
|
||||
// replace every bounding-box by none (react native doesn't support bounding-box)
|
||||
FS.writeFileSync(pageFilename, markup.replace(/bounding-box/g, "none"), {
|
||||
encoding: "utf-8",
|
||||
});
|
||||
}
|
||||
|
||||
// debug: log memory usage
|
||||
// const usage = process.memoryUsage()
|
||||
// for (const entry of Object.entries(usage)) {
|
||||
// if (entry[0] === 'rss') {
|
||||
// if (entry[1] > maxRss) {
|
||||
// maxRss = entry[1]
|
||||
// maxRssFilename = pageFilename
|
||||
// }
|
||||
// }
|
||||
// debug(entry[0] + ': ' + entry[1] / (1024 * 1024) + 'mb')
|
||||
// }
|
||||
// debug('maxRss: ' + (maxRss / 1024 / 1024) + 'mb' + ' for ' + maxRssFilename)
|
||||
}
|
||||
// debug('maxRss total: ' + (maxRss / 1024 / 1024) + 'mb' + ' for ' + maxRssFilename)
|
||||
|
||||
// await sleep(5000)
|
||||
// }) // end read file
|
||||
}
|
||||
|
||||
function debug(msg, debugEnabled = true) {
|
||||
if (debugEnabled) {
|
||||
console.log("[generateImages] " + msg);
|
||||
}
|
||||
}
|
||||
|
||||
// init();
|
||||
5
back/src/auth/apikey-auth.guard.ts
Normal file
5
back/src/auth/apikey-auth.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyAuthGuard extends AuthGuard("api-key") {}
|
||||
31
back/src/auth/apikey.strategy.ts
Normal file
31
back/src/auth/apikey.strategy.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import Strategy from "passport-headerapikey";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
@Injectable()
|
||||
export class HeaderApiKeyStrategy extends PassportStrategy(
|
||||
Strategy,
|
||||
"api-key",
|
||||
) {
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
super(
|
||||
{ header: "Authorization", prefix: "API Key " },
|
||||
true,
|
||||
async (apiKey, done) => {
|
||||
return this.validate(apiKey, done);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public validate = (apiKey: string, done: (error: Error, data) => {}) => {
|
||||
if (
|
||||
this.configService.get<string>("API_KEYS")?.split(",").includes(apiKey)
|
||||
) {
|
||||
//@ts-expect-error
|
||||
done(null, true);
|
||||
}
|
||||
done(new UnauthorizedException(), null);
|
||||
};
|
||||
}
|
||||
@@ -7,34 +7,53 @@ import {
|
||||
Body,
|
||||
Delete,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
HttpCode,
|
||||
Put,
|
||||
InternalServerErrorException,
|
||||
Patch,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { LocalAuthGuard } from './local-auth.guard';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { UsersService } from 'src/users/users.service';
|
||||
Req,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
HttpStatus,
|
||||
ParseFilePipeBuilder,
|
||||
Response,
|
||||
Query,
|
||||
Param,
|
||||
} from "@nestjs/common";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { JwtAuthGuard } from "./jwt-auth.guard";
|
||||
import { LocalAuthGuard } from "./local-auth.guard";
|
||||
import { RegisterDto } from "./dto/register.dto";
|
||||
import { UsersService } from "src/users/users.service";
|
||||
import {
|
||||
ApiBadRequestResponse,
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConflictResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { User } from '../models/user';
|
||||
import { JwtToken } from './models/jwt';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { Profile } from './dto/profile.dto';
|
||||
import { Setting } from 'src/models/setting';
|
||||
import { UpdateSettingDto } from 'src/settings/dto/update-setting.dto';
|
||||
import { SettingsService } from 'src/settings/settings.service';
|
||||
} from "@nestjs/swagger";
|
||||
import { User } from "../models/user";
|
||||
import { JwtToken } from "./models/jwt";
|
||||
import { LoginDto } from "./dto/login.dto";
|
||||
import { Profile } from "./dto/profile.dto";
|
||||
import { Setting } from "src/models/setting";
|
||||
import { UpdateSettingDto } from "src/settings/dto/update-setting.dto";
|
||||
import { SettingsService } from "src/settings/settings.service";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { writeFile } from "fs";
|
||||
import { PasswordResetDto } from "./dto/password_reset.dto ";
|
||||
import { mapInclude } from "src/utils/include";
|
||||
import { SongController } from "src/song/song.controller";
|
||||
import { ChromaAuthGuard } from "./chroma-auth.guard";
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
@ApiTags("auth")
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
@@ -42,49 +61,167 @@ export class AuthController {
|
||||
private settingsService: SettingsService,
|
||||
) {}
|
||||
|
||||
@Post('register')
|
||||
@Get("login/google")
|
||||
@UseGuards(AuthGuard("google"))
|
||||
@ApiOperation({ description: "Redirect to google login page" })
|
||||
googleLogin() {}
|
||||
|
||||
@Get("logged/google")
|
||||
@ApiOperation({
|
||||
description:
|
||||
"Redirect to the front page after connecting to the google account",
|
||||
})
|
||||
@UseGuards(AuthGuard("google"))
|
||||
async googleLoginCallbakc(@Req() req: any) {
|
||||
let user = await this.usersService.user({ googleID: req.user.googleID });
|
||||
if (!user) {
|
||||
user = await this.usersService.createUser(req.user);
|
||||
await this.settingsService.createUserSetting(user.id);
|
||||
}
|
||||
return this.authService.login(user);
|
||||
}
|
||||
|
||||
@Post("register")
|
||||
@ApiOperation({ description: "Register a new user" })
|
||||
@ApiConflictResponse({ description: "Username or email already taken" })
|
||||
@ApiOkResponse({
|
||||
description: "Successfully registered, email sent to verify",
|
||||
})
|
||||
@ApiBadRequestResponse({ description: "Invalid data or database error" })
|
||||
async register(@Body() registerDto: RegisterDto): Promise<void> {
|
||||
try {
|
||||
const user = await this.usersService.createUser(registerDto)
|
||||
const user = await this.usersService.createUser(registerDto);
|
||||
await this.settingsService.createUserSetting(user.id);
|
||||
} catch(e) {
|
||||
await this.authService.sendVerifyMail(user);
|
||||
} catch (e) {
|
||||
// check if the error is a duplicate key error
|
||||
if (e.code === "P2002") {
|
||||
throw new ConflictException("Username or email already taken");
|
||||
}
|
||||
console.error(e);
|
||||
throw new BadRequestException();
|
||||
}
|
||||
}
|
||||
|
||||
@Put("verify")
|
||||
@HttpCode(200)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ description: "Verify the email of the user" })
|
||||
@ApiOkResponse({ description: "Successfully verified" })
|
||||
@ApiBadRequestResponse({ description: "Invalid or expired token" })
|
||||
async verify(
|
||||
@Request() req: any,
|
||||
@Query("token") token: string,
|
||||
): Promise<void> {
|
||||
if (await this.authService.verifyMail(req.user.id, token)) return;
|
||||
throw new BadRequestException("Invalid token. Expired or invalid.");
|
||||
}
|
||||
|
||||
@Put("reverify")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ description: "Resend the verification email" })
|
||||
async reverify(@Request() req: any): Promise<void> {
|
||||
const user = await this.usersService.user({ id: req.user.id });
|
||||
if (!user) throw new BadRequestException("Invalid user");
|
||||
await this.authService.sendVerifyMail(user);
|
||||
}
|
||||
|
||||
@HttpCode(200)
|
||||
@Put("password-reset")
|
||||
async password_reset(
|
||||
@Body() resetDto: PasswordResetDto,
|
||||
@Query("token") token: string,
|
||||
): Promise<void> {
|
||||
if (await this.authService.changePassword(resetDto.password, token)) return;
|
||||
throw new BadRequestException("Invalid token. Expired or invalid.");
|
||||
}
|
||||
|
||||
@HttpCode(200)
|
||||
@Put("forgot-password")
|
||||
async forgot_password(@Query("email") email: string): Promise<void> {
|
||||
console.log(email);
|
||||
const user = await this.usersService.user({ email: email });
|
||||
if (!user) throw new BadRequestException("Invalid user");
|
||||
await this.authService.sendPasswordResetMail(user);
|
||||
}
|
||||
|
||||
@Post("login")
|
||||
@ApiBody({ type: LoginDto })
|
||||
@HttpCode(200)
|
||||
@UseGuards(LocalAuthGuard)
|
||||
@Post('login')
|
||||
@ApiBody({ type: LoginDto })
|
||||
@ApiOperation({ description: "Login with username and password" })
|
||||
@ApiOkResponse({ description: "Successfully logged in", type: JwtToken })
|
||||
@ApiUnauthorizedResponse({ description: "Invalid credentials" })
|
||||
async login(@Request() req: any): Promise<JwtToken> {
|
||||
return this.authService.login(req.user);
|
||||
}
|
||||
|
||||
@Post("guest")
|
||||
@HttpCode(200)
|
||||
@Post('guest')
|
||||
@ApiOperation({ description: "Login as a guest account" })
|
||||
@ApiOkResponse({ description: "Successfully logged in", type: JwtToken })
|
||||
async guest(): Promise<JwtToken> {
|
||||
const user = await this.usersService.createGuest();
|
||||
await this.settingsService.createUserSetting(user.id);
|
||||
return this.authService.login(user);
|
||||
}
|
||||
|
||||
@UseGuards(ChromaAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ description: "Get the profile picture of connected user" })
|
||||
@ApiOkResponse({ description: "The user profile picture" })
|
||||
@ApiUnauthorizedResponse({ description: "Invalid token" })
|
||||
@Get("me/picture")
|
||||
async getProfilePicture(@Request() req: any, @Response() res: any) {
|
||||
return await this.usersService.getProfilePicture(req.user.id, res);
|
||||
}
|
||||
|
||||
@UseGuards(ChromaAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOkResponse({ description: "The user profile picture" })
|
||||
@ApiUnauthorizedResponse({ description: "Invalid token" })
|
||||
@Post("me/picture")
|
||||
@ApiOperation({ description: "Upload a new profile picture" })
|
||||
@UseInterceptors(FileInterceptor("file"))
|
||||
async postProfilePicture(
|
||||
@Request() req: any,
|
||||
@UploadedFile(
|
||||
new ParseFilePipeBuilder()
|
||||
.addFileTypeValidator({
|
||||
fileType: "jpeg",
|
||||
})
|
||||
.build({
|
||||
errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
}),
|
||||
)
|
||||
file: Express.Multer.File,
|
||||
) {
|
||||
const path = `/data/${req.user.id}.jpg`;
|
||||
writeFile(path, file.buffer, (err) => {
|
||||
if (err) throw err;
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOkResponse({ description: 'Successfully logged in', type: User })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@Get('me')
|
||||
@ApiOkResponse({ description: "Successfully logged in", type: User })
|
||||
@ApiUnauthorizedResponse({ description: "Invalid token" })
|
||||
@Get("me")
|
||||
@ApiOperation({ description: "Get the user info of connected user" })
|
||||
async getProfile(@Request() req: any): Promise<User> {
|
||||
const user = await this.usersService.user({ id: req.user.id });
|
||||
if (!user) throw new InternalServerErrorException();
|
||||
return user;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(ChromaAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOkResponse({ description: 'Successfully edited profile', type: User })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@Put('me')
|
||||
@ApiOkResponse({ description: "Successfully edited profile", type: User })
|
||||
@ApiUnauthorizedResponse({ description: "Invalid token" })
|
||||
@Put("me")
|
||||
@ApiOperation({ description: "Edit the profile of connected user" })
|
||||
editProfile(
|
||||
@Request() req: any,
|
||||
@Body() profile: Partial<Profile>,
|
||||
@@ -107,35 +244,80 @@ export class AuthController {
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOkResponse({ description: 'Successfully deleted', type: User })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@Delete('me')
|
||||
@ApiOkResponse({ description: "Successfully deleted", type: User })
|
||||
@ApiUnauthorizedResponse({ description: "Invalid token" })
|
||||
@Delete("me")
|
||||
@ApiOperation({ description: "Delete the profile of connected user" })
|
||||
deleteSelf(@Request() req: any): Promise<User> {
|
||||
return this.usersService.deleteUser({ id: req.user.id });
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOkResponse({description: 'Successfully edited settings', type: Setting})
|
||||
@ApiUnauthorizedResponse({description: 'Invalid token'})
|
||||
@Patch('me/settings')
|
||||
@ApiOkResponse({ description: "Successfully edited settings", type: Setting })
|
||||
@ApiUnauthorizedResponse({ description: "Invalid token" })
|
||||
@Patch("me/settings")
|
||||
@ApiOperation({ description: "Edit the settings of connected user" })
|
||||
udpateSettings(
|
||||
@Request() req: any,
|
||||
@Body() settingUserDto: UpdateSettingDto): Promise<Setting> {
|
||||
@Body() settingUserDto: UpdateSettingDto,
|
||||
): Promise<Setting> {
|
||||
return this.settingsService.updateUserSettings({
|
||||
where: { userId: +req.user.id},
|
||||
where: { userId: +req.user.id },
|
||||
data: settingUserDto,
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOkResponse({description: 'Successfully edited settings', type: Setting})
|
||||
@ApiUnauthorizedResponse({description: 'Invalid token'})
|
||||
@Get('me/settings')
|
||||
@ApiOkResponse({ description: "Successfully edited settings", type: Setting })
|
||||
@ApiUnauthorizedResponse({ description: "Invalid token" })
|
||||
@Get("me/settings")
|
||||
@ApiOperation({ description: "Get the settings of connected user" })
|
||||
async getSettings(@Request() req: any): Promise<Setting> {
|
||||
const result = await this.settingsService.getUserSetting({ userId: +req.user.id });
|
||||
const result = await this.settingsService.getUserSetting({
|
||||
userId: +req.user.id,
|
||||
});
|
||||
if (!result) throw new NotFoundException();
|
||||
return result;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOkResponse({ description: "Successfully added liked song" })
|
||||
@ApiUnauthorizedResponse({ description: "Invalid token" })
|
||||
@Post("me/likes/:id")
|
||||
addLikedSong(@Request() req: any, @Param("id") songId: number) {
|
||||
return this.usersService.addLikedSong(+req.user.id, +songId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOkResponse({ description: "Successfully removed liked song" })
|
||||
@ApiUnauthorizedResponse({ description: "Invalid token" })
|
||||
@Delete("me/likes/:id")
|
||||
removeLikedSong(@Request() req: any, @Param("id") songId: number) {
|
||||
return this.usersService.removeLikedSong(+req.user.id, +songId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOkResponse({ description: "Successfully retrieved liked song" })
|
||||
@ApiUnauthorizedResponse({ description: "Invalid token" })
|
||||
@Get("me/likes")
|
||||
getLikedSongs(@Request() req: any, @Query("include") include: string) {
|
||||
return this.usersService.getLikedSongs(
|
||||
+req.user.id,
|
||||
mapInclude(include, req, SongController.includableFields),
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOkResponse({ description: "Successfully added score" })
|
||||
@ApiUnauthorizedResponse({ description: "Invalid token" })
|
||||
@Patch("me/score/:score")
|
||||
addScore(@Request() req: any, @Param("id") score: number) {
|
||||
return this.usersService.addScore(+req.user.id, score);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersModule } from 'src/users/users.module';
|
||||
import { AuthService } from './auth.service';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { LocalStrategy } from './local.strategy';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { SettingsModule } from 'src/settings/settings.module';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { UsersModule } from "src/users/users.module";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { PassportModule } from "@nestjs/passport";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { LocalStrategy } from "./local.strategy";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { JwtStrategy } from "./jwt.strategy";
|
||||
import { SettingsModule } from "src/settings/settings.module";
|
||||
import { GoogleStrategy } from "./google.strategy";
|
||||
import { HeaderApiKeyStrategy } from "./apikey.strategy";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -19,13 +21,19 @@ import { SettingsModule } from 'src/settings/settings.module';
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
secret: configService.get('JWT_SECRET'),
|
||||
signOptions: { expiresIn: '1h' },
|
||||
secret: configService.get("JWT_SECRET"),
|
||||
signOptions: { expiresIn: "365d" },
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, LocalStrategy, JwtStrategy],
|
||||
providers: [
|
||||
AuthService,
|
||||
LocalStrategy,
|
||||
JwtStrategy,
|
||||
GoogleStrategy,
|
||||
HeaderApiKeyStrategy,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import PayloadInterface from './interface/payload.interface';
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { UsersService } from "../users/users.service";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import * as bcrypt from "bcryptjs";
|
||||
import PayloadInterface from "./interface/payload.interface";
|
||||
import { User } from "src/models/user";
|
||||
import { MailerService } from "@nestjs-modules/mailer";
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private userService: UsersService,
|
||||
private jwtService: JwtService,
|
||||
private emailService: MailerService,
|
||||
) {}
|
||||
|
||||
validateApiKey(apikey: string): boolean {
|
||||
if (process.env.API_KEYS == null) return false;
|
||||
const keys = process.env.API_KEYS.split(",");
|
||||
return keys.includes(apikey);
|
||||
}
|
||||
|
||||
async validateUser(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<PayloadInterface | null> {
|
||||
const user = await this.userService.user({ username });
|
||||
if (user && bcrypt.compareSync(password, user.password)) {
|
||||
if (user && user.password && bcrypt.compareSync(password, user.password)) {
|
||||
return {
|
||||
username: user.username,
|
||||
id: user.id,
|
||||
@@ -31,4 +40,70 @@ export class AuthService {
|
||||
access_token,
|
||||
};
|
||||
}
|
||||
|
||||
async sendVerifyMail(user: User) {
|
||||
if (process.env.IGNORE_MAILS === "true") return;
|
||||
if (user.email == null) return;
|
||||
console.log("Sending verification mail to", user.email);
|
||||
const token = await this.jwtService.signAsync(
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
{ expiresIn: "10h" },
|
||||
);
|
||||
await this.emailService.sendMail({
|
||||
to: user.email,
|
||||
from: "chromacase@octohub.app",
|
||||
subject: "Mail verification for Chromacase",
|
||||
html: `To verify your mail, please click on this <a href="${process.env.PUBLIC_URL}/verify?token=${token}">link</a>.`,
|
||||
});
|
||||
}
|
||||
|
||||
async sendPasswordResetMail(user: User) {
|
||||
if (process.env.IGNORE_MAILS === "true") return;
|
||||
if (user.email == null) return;
|
||||
console.log("Sending password reset mail to", user.email);
|
||||
const token = await this.jwtService.signAsync(
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
{ expiresIn: "10h" },
|
||||
);
|
||||
await this.emailService.sendMail({
|
||||
to: user.email,
|
||||
from: "chromacase@octohub.app",
|
||||
subject: "Password reset for Chromacase",
|
||||
html: `To reset your password, please click on this <a href="${process.env.PUBLIC_URL}/password_reset?token=${token}">link</a>.`,
|
||||
});
|
||||
}
|
||||
|
||||
async changePassword(new_password: string, token: string): Promise<boolean> {
|
||||
let verified;
|
||||
try {
|
||||
verified = await this.jwtService.verifyAsync(token);
|
||||
} catch (e) {
|
||||
console.log("Password reset token failure", e);
|
||||
return false;
|
||||
}
|
||||
console.log(verified);
|
||||
await this.userService.updateUser({
|
||||
where: { id: verified.userId },
|
||||
data: { password: new_password },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async verifyMail(userId: number, token: string): Promise<boolean> {
|
||||
try {
|
||||
await this.jwtService.verifyAsync(token);
|
||||
} catch (e) {
|
||||
console.log("Verify mail token failure", e);
|
||||
return false;
|
||||
}
|
||||
await this.userService.updateUser({
|
||||
where: { id: userId },
|
||||
data: { emailVerified: true },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
22
back/src/auth/chroma-auth.guard.ts
Normal file
22
back/src/auth/chroma-auth.guard.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ExecutionContext, Injectable } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
import { IS_PUBLIC_KEY } from "./public";
|
||||
|
||||
@Injectable()
|
||||
export class ChromaAuthGuard extends AuthGuard(["jwt", "api-key"]) {
|
||||
constructor(private reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
return super.canActivate(context);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
@Injectable()
|
||||
export class Constants {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
getSecret = () => {
|
||||
return this.configService.get('JWT_SECRET');
|
||||
return this.configService.get("JWT_SECRET");
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty } from "class-validator";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty()
|
||||
|
||||
8
back/src/auth/dto/password_reset.dto .ts
Normal file
8
back/src/auth/dto/password_reset.dto .ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsNotEmpty } from "class-validator";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class PasswordResetDto {
|
||||
@ApiProperty()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty } from "class-validator";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class Profile {
|
||||
@ApiProperty()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty } from "class-validator";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty()
|
||||
|
||||
35
back/src/auth/google.strategy.ts
Normal file
35
back/src/auth/google.strategy.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { Strategy, VerifyCallback } from "passport-google-oauth20";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class GoogleStrategy extends PassportStrategy(Strategy) {
|
||||
constructor() {
|
||||
super({
|
||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_SECRET,
|
||||
callbackURL: process.env.GOOGLE_CALLBACK_URL,
|
||||
scope: ["email", "profile"],
|
||||
});
|
||||
}
|
||||
|
||||
async validate(
|
||||
_accessToken: string,
|
||||
_refreshToken: string,
|
||||
profile: any,
|
||||
done: VerifyCallback,
|
||||
): Promise<any> {
|
||||
const user = {
|
||||
email: profile.emails[0].value,
|
||||
username: profile.displayName,
|
||||
password: null,
|
||||
googleID: profile.id,
|
||||
// firstName: name.givenName,
|
||||
// lastName: name.familyName,
|
||||
// picture: photos[0].value,
|
||||
};
|
||||
done(null, user);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,22 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ExecutionContext, Injectable } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
import { IS_PUBLIC_KEY } from "./public";
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
export class JwtAuthGuard extends AuthGuard("jwt") {
|
||||
constructor(private reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
return super.canActivate(context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ExtractJwt, Strategy } from "passport-jwt";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
@@ -9,7 +9,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get('JWT_SECRET'),
|
||||
secretOrKey: configService.get("JWT_SECRET"),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
|
||||
@Injectable()
|
||||
export class LocalAuthGuard extends AuthGuard('local') {}
|
||||
export class LocalAuthGuard extends AuthGuard("local") {}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Strategy } from 'passport-local';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import PayloadInterface from './interface/payload.interface';
|
||||
import { Strategy } from "passport-local";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { AuthService } from "./auth.service";
|
||||
import PayloadInterface from "./interface/payload.interface";
|
||||
|
||||
@Injectable()
|
||||
export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class JwtToken {
|
||||
@ApiProperty()
|
||||
|
||||
4
back/src/auth/public.ts
Normal file
4
back/src/auth/public.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from "@nestjs/common";
|
||||
|
||||
export const IS_PUBLIC_KEY = "isPublic";
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsNotEmpty } from "class-validator";
|
||||
|
||||
export class CreateGenreDto {
|
||||
@IsNotEmpty()
|
||||
|
||||
@@ -13,20 +13,30 @@ import {
|
||||
Query,
|
||||
Req,
|
||||
StreamableFile,
|
||||
} from '@nestjs/common';
|
||||
import { Plage } from 'src/models/plage';
|
||||
import { CreateGenreDto } from './dto/create-genre.dto';
|
||||
import { Request } from 'express';
|
||||
import { GenreService } from './genre.service';
|
||||
import { Prisma, Genre } from '@prisma/client';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { createReadStream, existsSync } from 'fs';
|
||||
import { FilterQuery } from 'src/utils/filter.pipe';
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOkResponsePlaginated, Plage } from "src/models/plage";
|
||||
import { CreateGenreDto } from "./dto/create-genre.dto";
|
||||
import { Request } from "express";
|
||||
import { GenreService } from "./genre.service";
|
||||
import { Prisma, Genre } from "@prisma/client";
|
||||
import { ApiTags } from "@nestjs/swagger";
|
||||
import { createReadStream, existsSync } from "fs";
|
||||
import { FilterQuery } from "src/utils/filter.pipe";
|
||||
import { Genre as _Genre } from "src/_gen/prisma-class/genre";
|
||||
import { IncludeMap, mapInclude } from "src/utils/include";
|
||||
import { Public } from "src/auth/public";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
import { ChromaAuthGuard } from "src/auth/chroma-auth.guard";
|
||||
|
||||
@Controller('genre')
|
||||
@ApiTags('genre')
|
||||
@Controller("genre")
|
||||
@ApiTags("genre")
|
||||
@UseGuards(ChromaAuthGuard)
|
||||
export class GenreController {
|
||||
static filterableFields: string[] = ['+id', 'name'];
|
||||
static filterableFields: string[] = ["+id", "name"];
|
||||
static includableFields: IncludeMap<Prisma.GenreInclude> = {
|
||||
Song: true,
|
||||
};
|
||||
|
||||
constructor(private readonly service: GenreService) {}
|
||||
|
||||
@@ -39,22 +49,23 @@ export class GenreController {
|
||||
}
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@Param('id', ParseIntPipe) id: number) {
|
||||
@Delete(":id")
|
||||
async remove(@Param("id", ParseIntPipe) id: number) {
|
||||
try {
|
||||
return await this.service.delete({ id });
|
||||
} catch {
|
||||
throw new NotFoundException('Invalid ID');
|
||||
throw new NotFoundException("Invalid ID");
|
||||
}
|
||||
}
|
||||
|
||||
@Get(':id/illustration')
|
||||
async getIllustration(@Param('id', ParseIntPipe) id: number) {
|
||||
@Get(":id/illustration")
|
||||
@Public()
|
||||
async getIllustration(@Param("id", ParseIntPipe) id: number) {
|
||||
const genre = await this.service.get({ id });
|
||||
if (!genre) throw new NotFoundException('Genre not found');
|
||||
if (!genre) throw new NotFoundException("Genre not found");
|
||||
const path = `/assets/genres/${genre.name}/illustration.png`;
|
||||
if (!existsSync(path))
|
||||
throw new NotFoundException('Illustration not found');
|
||||
throw new NotFoundException("Illustration not found");
|
||||
|
||||
try {
|
||||
const file = createReadStream(path);
|
||||
@@ -65,26 +76,36 @@ export class GenreController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOkResponsePlaginated(_Genre)
|
||||
async findAll(
|
||||
@Req() req: Request,
|
||||
@FilterQuery(GenreController.filterableFields)
|
||||
where: Prisma.GenreWhereInput,
|
||||
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||
@Query("include") include: string,
|
||||
@Query("skip", new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||
@Query("take", new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||
): Promise<Plage<Genre>> {
|
||||
const ret = await this.service.list({
|
||||
skip,
|
||||
take,
|
||||
where,
|
||||
include: mapInclude(include, req, GenreController.includableFields),
|
||||
});
|
||||
return new Plage(ret, req);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
const res = await this.service.get({ id });
|
||||
@Get(":id")
|
||||
async findOne(
|
||||
@Req() req: Request,
|
||||
@Query("include") include: string,
|
||||
@Param("id", ParseIntPipe) id: number,
|
||||
) {
|
||||
const res = await this.service.get(
|
||||
{ id },
|
||||
mapInclude(include, req, GenreController.includableFields),
|
||||
);
|
||||
|
||||
if (res === null) throw new NotFoundException('Genre not found');
|
||||
if (res === null) throw new NotFoundException("Genre not found");
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
import { GenreController } from './genre.controller';
|
||||
import { GenreService } from './genre.service';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PrismaModule } from "src/prisma/prisma.module";
|
||||
import { GenreController } from "./genre.controller";
|
||||
import { GenreService } from "./genre.service";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma, Genre } from '@prisma/client';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma, Genre } from "@prisma/client";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class GenreService {
|
||||
@@ -12,9 +12,13 @@ export class GenreService {
|
||||
});
|
||||
}
|
||||
|
||||
async get(where: Prisma.GenreWhereUniqueInput): Promise<Genre | null> {
|
||||
async get(
|
||||
where: Prisma.GenreWhereUniqueInput,
|
||||
include?: Prisma.GenreInclude,
|
||||
): Promise<Genre | null> {
|
||||
return this.prisma.genre.findUnique({
|
||||
where,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,14 +28,16 @@ export class GenreService {
|
||||
cursor?: Prisma.GenreWhereUniqueInput;
|
||||
where?: Prisma.GenreWhereInput;
|
||||
orderBy?: Prisma.GenreOrderByWithRelationInput;
|
||||
include?: Prisma.GenreInclude;
|
||||
}): Promise<Genre[]> {
|
||||
const { skip, take, cursor, where, orderBy } = params;
|
||||
const { skip, take, cursor, where, orderBy, include } = params;
|
||||
return this.prisma.genre.findMany({
|
||||
skip,
|
||||
take,
|
||||
cursor,
|
||||
where,
|
||||
orderBy,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ export class SongHistoryDto {
|
||||
score: number;
|
||||
|
||||
@ApiProperty()
|
||||
difficulties: Record<string, number>
|
||||
difficulties: Record<string, number>;
|
||||
|
||||
@ApiProperty()
|
||||
info: Record<string, number>
|
||||
info: Record<string, number>;
|
||||
}
|
||||
|
||||
@@ -9,57 +9,82 @@ import {
|
||||
Query,
|
||||
Request,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger';
|
||||
import { SearchHistory, SongHistory } from '@prisma/client';
|
||||
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
|
||||
import { SongHistoryDto } from './dto/SongHistoryDto';
|
||||
import { HistoryService } from './history.service';
|
||||
import { SearchHistoryDto } from './dto/SearchHistoryDto';
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiCreatedResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from "@nestjs/swagger";
|
||||
import { SearchHistory, SongHistory } from "@prisma/client";
|
||||
import { JwtAuthGuard } from "src/auth/jwt-auth.guard";
|
||||
import { SongHistoryDto } from "./dto/SongHistoryDto";
|
||||
import { HistoryService } from "./history.service";
|
||||
import { SearchHistoryDto } from "./dto/SearchHistoryDto";
|
||||
import { SongHistory as _SongHistory } from "src/_gen/prisma-class/song_history";
|
||||
import { SearchHistory as _SearchHistory } from "src/_gen/prisma-class/search_history";
|
||||
import { SongController } from "src/song/song.controller";
|
||||
import { mapInclude } from "src/utils/include";
|
||||
|
||||
@Controller('history')
|
||||
@ApiTags('history')
|
||||
@Controller("history")
|
||||
@ApiTags("history")
|
||||
export class HistoryController {
|
||||
constructor(private readonly historyService: HistoryService) { }
|
||||
constructor(private readonly historyService: HistoryService) {}
|
||||
|
||||
@Get()
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ description: "Get song history of connected user" })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@ApiOkResponse({ type: _SongHistory, isArray: true })
|
||||
@ApiUnauthorizedResponse({ description: "Invalid token" })
|
||||
async getHistory(
|
||||
@Request() req: any,
|
||||
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||
@Query("skip", new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||
@Query("take", new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||
@Query("include") include: string,
|
||||
): Promise<SongHistory[]> {
|
||||
return this.historyService.getHistory(req.user.id, { skip, take });
|
||||
return this.historyService.getHistory(
|
||||
req.user.id,
|
||||
{ skip, take },
|
||||
mapInclude(include, req, SongController.includableFields),
|
||||
);
|
||||
}
|
||||
|
||||
@Get('search')
|
||||
@Get("search")
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ description: "Get search history of connected user" })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@ApiOkResponse({ type: _SearchHistory, isArray: true })
|
||||
@ApiUnauthorizedResponse({ description: "Invalid token" })
|
||||
async getSearchHistory(
|
||||
@Request() req: any,
|
||||
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||
@Query("skip", new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||
@Query("take", new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||
): Promise<SearchHistory[]> {
|
||||
return this.historyService.getSearchHistory(req.user.id, { skip, take });
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(201)
|
||||
@ApiOperation({ description: "Create a record of a song played by a user" })
|
||||
@ApiCreatedResponse({ description: "Succesfully created a record" })
|
||||
async create(@Body() record: SongHistoryDto): Promise<SongHistory> {
|
||||
return this.historyService.createSongHistoryRecord(record);
|
||||
}
|
||||
|
||||
@Post("search")
|
||||
@HttpCode(201)
|
||||
@ApiOperation({ description: "Creates a search record in the users history" })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiUnauthorizedResponse({description: "Invalid token"})
|
||||
@ApiUnauthorizedResponse({ description: "Invalid token" })
|
||||
async createSearchHistory(
|
||||
@Request() req: any,
|
||||
@Body() record: SearchHistoryDto
|
||||
): Promise<void> {
|
||||
await this.historyService.createSearchHistoryRecord(req.user.id, { query: record.query, type: record.type });
|
||||
}
|
||||
@Body() record: SearchHistoryDto,
|
||||
): Promise<void> {
|
||||
await this.historyService.createSearchHistoryRecord(req.user.id, {
|
||||
query: record.query,
|
||||
type: record.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
import { HistoryService } from './history.service';
|
||||
import { HistoryController } from './history.controller';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PrismaModule } from "src/prisma/prisma.module";
|
||||
import { HistoryService } from "./history.service";
|
||||
import { HistoryController } from "./history.controller";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { HistoryService } from './history.service';
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { HistoryService } from "./history.service";
|
||||
|
||||
describe('HistoryService', () => {
|
||||
let service: HistoryService;
|
||||
describe("HistoryService", () => {
|
||||
let service: HistoryService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [HistoryService],
|
||||
}).compile();
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [HistoryService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<HistoryService>(HistoryService);
|
||||
});
|
||||
service = module.get<HistoryService>(HistoryService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SearchHistory, SongHistory } from '@prisma/client';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { SearchHistoryDto } from './dto/SearchHistoryDto';
|
||||
import { SongHistoryDto } from './dto/SongHistoryDto';
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma, SearchHistory, SongHistory } from "@prisma/client";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { SearchHistoryDto } from "./dto/SearchHistoryDto";
|
||||
import { SongHistoryDto } from "./dto/SongHistoryDto";
|
||||
|
||||
@Injectable()
|
||||
export class HistoryService {
|
||||
constructor(private prisma: PrismaService) { }
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async createSongHistoryRecord({
|
||||
songID,
|
||||
@@ -45,12 +45,14 @@ export class HistoryService {
|
||||
async getHistory(
|
||||
playerId: number,
|
||||
{ skip, take }: { skip?: number; take?: number },
|
||||
include?: Prisma.SongInclude,
|
||||
): Promise<SongHistory[]> {
|
||||
return this.prisma.songHistory.findMany({
|
||||
where: { user: { id: playerId } },
|
||||
orderBy: { playDate: 'desc' },
|
||||
orderBy: { playDate: "desc" },
|
||||
skip,
|
||||
take,
|
||||
include: { song: include ? { include } : true },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,7 +65,7 @@ export class HistoryService {
|
||||
}): Promise<{ best: number; history: SongHistory[] }> {
|
||||
const history = await this.prisma.songHistory.findMany({
|
||||
where: { user: { id: playerId }, song: { id: songId } },
|
||||
orderBy: { playDate: 'desc' },
|
||||
orderBy: { playDate: "desc" },
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -74,7 +76,7 @@ export class HistoryService {
|
||||
|
||||
async createSearchHistoryRecord(
|
||||
userID: number,
|
||||
{ query, type }: SearchHistoryDto
|
||||
{ query, type }: SearchHistoryDto,
|
||||
): Promise<SearchHistory> {
|
||||
return this.prisma.searchHistory.create({
|
||||
data: {
|
||||
@@ -95,7 +97,7 @@ export class HistoryService {
|
||||
): Promise<SearchHistory[]> {
|
||||
return this.prisma.searchHistory.findMany({
|
||||
where: { user: { id: playerId } },
|
||||
orderBy: { searchDate: 'desc' },
|
||||
orderBy: { searchDate: "desc" },
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Get,
|
||||
Query,
|
||||
Req,
|
||||
Request,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
DefaultValuePipe,
|
||||
@@ -12,12 +11,18 @@ import {
|
||||
Body,
|
||||
Delete,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Plage } from 'src/models/plage';
|
||||
import { LessonService } from './lesson.service';
|
||||
import { ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger';
|
||||
import { Prisma, Skill } from '@prisma/client';
|
||||
import { FilterQuery } from 'src/utils/filter.pipe';
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOkResponsePlaginated, Plage } from "src/models/plage";
|
||||
import { LessonService } from "./lesson.service";
|
||||
import { ApiOperation, ApiProperty, ApiTags } from "@nestjs/swagger";
|
||||
import { Prisma, Skill } from "@prisma/client";
|
||||
import { FilterQuery } from "src/utils/filter.pipe";
|
||||
import { Lesson as _Lesson } from "src/_gen/prisma-class/lesson";
|
||||
import { IncludeMap, mapInclude } from "src/utils/include";
|
||||
import { Request } from "express";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
import { ChromaAuthGuard } from "src/auth/chroma-auth.guard";
|
||||
|
||||
export class Lesson {
|
||||
@ApiProperty()
|
||||
@@ -32,49 +37,63 @@ export class Lesson {
|
||||
mainSkill: Skill;
|
||||
}
|
||||
|
||||
@ApiTags('lessons')
|
||||
@Controller('lesson')
|
||||
@ApiTags("lessons")
|
||||
@Controller("lesson")
|
||||
@UseGuards(ChromaAuthGuard)
|
||||
export class LessonController {
|
||||
static filterableFields: string[] = [
|
||||
'+id',
|
||||
'name',
|
||||
'+requiredLevel',
|
||||
'mainSkill',
|
||||
"+id",
|
||||
"name",
|
||||
"+requiredLevel",
|
||||
"mainSkill",
|
||||
];
|
||||
static includableFields: IncludeMap<Prisma.LessonInclude> = {
|
||||
LessonHistory: true,
|
||||
};
|
||||
|
||||
constructor(private lessonService: LessonService) {}
|
||||
|
||||
@ApiOperation({
|
||||
summary: 'Get all lessons',
|
||||
summary: "Get all lessons",
|
||||
})
|
||||
@Get()
|
||||
@ApiOkResponsePlaginated(_Lesson)
|
||||
async getAll(
|
||||
@Req() request: Request,
|
||||
@FilterQuery(LessonController.filterableFields)
|
||||
where: Prisma.LessonWhereInput,
|
||||
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||
@Query("include") include: string,
|
||||
@Query("skip", new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||
@Query("take", new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||
): Promise<Plage<Lesson>> {
|
||||
const ret = await this.lessonService.getAll({
|
||||
skip,
|
||||
take,
|
||||
where,
|
||||
include: mapInclude(include, request, LessonController.includableFields),
|
||||
});
|
||||
return new Plage(ret, request);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: 'Get a particular lessons',
|
||||
summary: "Get a particular lessons",
|
||||
})
|
||||
@Get(':id')
|
||||
async get(@Param('id', ParseIntPipe) id: number): Promise<Lesson> {
|
||||
const ret = await this.lessonService.get(id);
|
||||
@Get(":id")
|
||||
async get(
|
||||
@Req() req: Request,
|
||||
@Query("include") include: string,
|
||||
@Param("id", ParseIntPipe) id: number,
|
||||
): Promise<Lesson> {
|
||||
const ret = await this.lessonService.get(
|
||||
id,
|
||||
mapInclude(include, req, LessonController.includableFields),
|
||||
);
|
||||
if (!ret) throw new NotFoundException();
|
||||
return ret;
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: 'Create a lessons',
|
||||
summary: "Create a lessons",
|
||||
})
|
||||
@Post()
|
||||
async post(@Body() lesson: Lesson): Promise<Lesson> {
|
||||
@@ -87,10 +106,10 @@ export class LessonController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: 'Delete a lessons',
|
||||
summary: "Delete a lessons",
|
||||
})
|
||||
@Delete(':id')
|
||||
async delete(@Param('id', ParseIntPipe) id: number): Promise<Lesson> {
|
||||
@Delete(":id")
|
||||
async delete(@Param("id", ParseIntPipe) id: number): Promise<Lesson> {
|
||||
try {
|
||||
return await this.lessonService.delete(id);
|
||||
} catch {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
import { LessonController } from './lesson.controller';
|
||||
import { LessonService } from './lesson.service';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PrismaModule } from "src/prisma/prisma.module";
|
||||
import { LessonController } from "./lesson.controller";
|
||||
import { LessonService } from "./lesson.service";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { LessonService } from './lesson.service';
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { LessonService } from "./lesson.service";
|
||||
|
||||
describe('LessonService', () => {
|
||||
describe("LessonService", () => {
|
||||
let service: LessonService;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -12,7 +12,7 @@ describe('LessonService', () => {
|
||||
service = module.get<LessonService>(LessonService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Lesson, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Lesson, Prisma } from "@prisma/client";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class LessonService {
|
||||
@@ -12,22 +12,28 @@ export class LessonService {
|
||||
cursor?: Prisma.LessonWhereUniqueInput;
|
||||
where?: Prisma.LessonWhereInput;
|
||||
orderBy?: Prisma.LessonOrderByWithRelationInput;
|
||||
include?: Prisma.LessonInclude;
|
||||
}): Promise<Lesson[]> {
|
||||
const { skip, take, cursor, where, orderBy } = params;
|
||||
const { skip, take, cursor, where, orderBy, include } = params;
|
||||
return this.prisma.lesson.findMany({
|
||||
skip,
|
||||
take,
|
||||
cursor,
|
||||
where,
|
||||
orderBy,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
async get(id: number): Promise<Lesson | null> {
|
||||
async get(
|
||||
id: number,
|
||||
include?: Prisma.LessonInclude,
|
||||
): Promise<Lesson | null> {
|
||||
return this.prisma.lesson.findFirst({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,72 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { PrismaService } from './prisma/prisma.service';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { AppModule } from "./app.module";
|
||||
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ValidationPipe,
|
||||
} from "@nestjs/common";
|
||||
import { RequestLogger, RequestLoggerOptions } from "json-logger-service";
|
||||
import { tap } from "rxjs";
|
||||
import { PrismaModel } from "./_gen/prisma-class";
|
||||
import { PrismaService } from "./prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class AspectLogger implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler) {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const res = context.switchToHttp().getResponse();
|
||||
const { statusCode } = context.switchToHttp().getResponse();
|
||||
const { originalUrl, method, params, query, body, user } = req;
|
||||
|
||||
const toPrint = {
|
||||
originalUrl,
|
||||
method,
|
||||
params,
|
||||
query,
|
||||
body,
|
||||
userId: user?.id ?? "not logged in",
|
||||
username: user?.username ?? "not logged in",
|
||||
};
|
||||
|
||||
return next.handle().pipe(
|
||||
tap((/* data */) =>
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
...toPrint,
|
||||
statusCode,
|
||||
//data, //TODO: Data crashed with images
|
||||
}),
|
||||
),),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const prismaService = app.get(PrismaService);
|
||||
await prismaService.enableShutdownHooks(app);
|
||||
app.use(
|
||||
RequestLogger.buildExpressRequestLogger({
|
||||
doNotLogPaths: ["/health"],
|
||||
} as RequestLoggerOptions),
|
||||
);
|
||||
app.enableShutdownHooks();
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Chromacase')
|
||||
.setDescription('The chromacase API')
|
||||
.setVersion('1.0')
|
||||
.setTitle("Chromacase")
|
||||
.setDescription("The chromacase API")
|
||||
.setVersion("1.0")
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api', app, document);
|
||||
const document = SwaggerModule.createDocument(app, config, {
|
||||
extraModels: [...PrismaModel.extraModels],
|
||||
});
|
||||
SwaggerModule.setup("api", app, document);
|
||||
|
||||
app.useGlobalPipes(new ValidationPipe());
|
||||
app.enableCors();
|
||||
//app.useGlobalInterceptors(new AspectLogger());
|
||||
|
||||
await app.listen(3000);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -2,23 +2,42 @@
|
||||
* Thanks to https://github.com/Arthi-chaud/Meelo/blob/master/src/pagination/models/paginated-response.ts
|
||||
*/
|
||||
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type, applyDecorators } from "@nestjs/common";
|
||||
import {
|
||||
ApiExtraModels,
|
||||
ApiOkResponse,
|
||||
ApiProperty,
|
||||
getSchemaPath,
|
||||
} from "@nestjs/swagger";
|
||||
|
||||
export class Plage<T> {
|
||||
export class PlageMetadata {
|
||||
@ApiProperty()
|
||||
metadata: {
|
||||
this: string;
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
};
|
||||
this: string;
|
||||
@ApiProperty({
|
||||
type: "string",
|
||||
nullable: true,
|
||||
description: "null if there is no next page, couldn't set it in swagger",
|
||||
})
|
||||
next: string | null;
|
||||
@ApiProperty({
|
||||
type: "string",
|
||||
nullable: true,
|
||||
description:
|
||||
"null if there is no previous page, couldn't set it in swagger",
|
||||
})
|
||||
previous: string | null;
|
||||
}
|
||||
|
||||
export class Plage<T extends object> {
|
||||
@ApiProperty()
|
||||
metadata: PlageMetadata;
|
||||
data: T[];
|
||||
|
||||
constructor(data: T[], request: Request | any) {
|
||||
this.data = data;
|
||||
let take = Number(request.query['take'] ?? 20).valueOf();
|
||||
let take = Number(request.query["take"] ?? 20).valueOf();
|
||||
if (take == 0) take = 20;
|
||||
let skipped: number = Number(request.query['skip'] ?? 0).valueOf();
|
||||
let skipped: number = Number(request.query["skip"] ?? 0).valueOf();
|
||||
if (skipped % take) {
|
||||
skipped += take - (skipped % take);
|
||||
}
|
||||
@@ -49,3 +68,25 @@ export class Plage<T> {
|
||||
return route;
|
||||
}
|
||||
}
|
||||
|
||||
export const ApiOkResponsePlaginated = <DataDto extends Type<unknown>>(
|
||||
dataDto: DataDto,
|
||||
) =>
|
||||
applyDecorators(
|
||||
ApiExtraModels(Plage, dataDto),
|
||||
ApiOkResponse({
|
||||
schema: {
|
||||
allOf: [
|
||||
{ $ref: getSchemaPath(Plage) },
|
||||
{
|
||||
properties: {
|
||||
data: {
|
||||
type: "array",
|
||||
items: { $ref: getSchemaPath(dataDto) },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class Setting {
|
||||
@ApiProperty()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class User {
|
||||
@ApiProperty()
|
||||
@@ -6,9 +6,11 @@ export class User {
|
||||
@ApiProperty()
|
||||
username: string;
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
email: string | null;
|
||||
@ApiProperty()
|
||||
isGuest: boolean;
|
||||
@ApiProperty()
|
||||
partyPlayed: number;
|
||||
@ApiProperty()
|
||||
totalScore: number;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PrismaService } from "./prisma.service";
|
||||
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { PrismaService } from './prisma.service';
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { PrismaService } from "./prisma.service";
|
||||
|
||||
describe('PrismaService', () => {
|
||||
describe("PrismaService", () => {
|
||||
let service: PrismaService;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -12,7 +12,7 @@ describe('PrismaService', () => {
|
||||
service = module.get<PrismaService>(PrismaService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { Injectable, OnModuleInit } from "@nestjs/common";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async enableShutdownHooks(app: INestApplication) {
|
||||
this.$on('beforeExit', async () => {
|
||||
await app.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
16
back/src/scores/scores.controller.ts
Normal file
16
back/src/scores/scores.controller.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { ApiOkResponse, ApiTags } from "@nestjs/swagger";
|
||||
import { ScoresService } from "./scores.service";
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
@ApiTags("scores")
|
||||
@Controller("scores")
|
||||
export class ScoresController {
|
||||
constructor(private readonly scoresService: ScoresService) {}
|
||||
|
||||
@ApiOkResponse({ description: "Successfully sent the Top 20 players" })
|
||||
@Get("top/20")
|
||||
getTopTwenty(): Promise<User[]> {
|
||||
return this.scoresService.topTwenty();
|
||||
}
|
||||
}
|
||||
11
back/src/scores/scores.module.ts
Normal file
11
back/src/scores/scores.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ScoresService } from "./scores.service";
|
||||
import { ScoresController } from "./scores.controller";
|
||||
import { PrismaModule } from "src/prisma/prisma.module";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [ScoresController],
|
||||
providers: [ScoresService],
|
||||
})
|
||||
export class ScoresModule {}
|
||||
17
back/src/scores/scores.service.ts
Normal file
17
back/src/scores/scores.service.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class ScoresService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async topTwenty(): Promise<User[]> {
|
||||
return this.prisma.user.findMany({
|
||||
orderBy: {
|
||||
totalScore: "desc",
|
||||
},
|
||||
take: 20,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class SearchSongDto {
|
||||
@ApiProperty()
|
||||
|
||||
29
back/src/search/meilisearch.service.ts
Normal file
29
back/src/search/meilisearch.service.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Injectable, OnModuleInit } from "@nestjs/common";
|
||||
import MeiliSearch, { DocumentOptions, Settings } from "meilisearch";
|
||||
|
||||
@Injectable()
|
||||
export class MeiliService extends MeiliSearch implements OnModuleInit {
|
||||
constructor() {
|
||||
super({
|
||||
host: process.env.MEILI_ADDR || "http://meilisearch:7700",
|
||||
apiKey: process.env.MEILI_MASTER_KEY,
|
||||
});
|
||||
}
|
||||
|
||||
async definedIndex(uid: string, opts: Settings) {
|
||||
let task = await this.createIndex(uid, { primaryKey: "id" });
|
||||
await this.waitForTask(task.taskUid);
|
||||
task = await this.index(uid).updateSettings(opts);
|
||||
await this.waitForTask(task.taskUid);
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.definedIndex("songs", {
|
||||
searchableAttributes: ["name", "artist"],
|
||||
filterableAttributes: ["artistId", "genreId"],
|
||||
});
|
||||
await this.definedIndex("artists", {
|
||||
searchableAttributes: ["name"],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,61 +1,78 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
Get,
|
||||
HttpCode,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
Request,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
||||
import { Artist, Genre, Song } from '@prisma/client';
|
||||
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
|
||||
import { SearchSongDto } from './dto/search-song.dto';
|
||||
import { SearchService } from './search.service';
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from "@nestjs/swagger";
|
||||
import { Artist, Genre, Song } from "@prisma/client";
|
||||
import { JwtAuthGuard } from "src/auth/jwt-auth.guard";
|
||||
import { SearchService } from "./search.service";
|
||||
import { Song as _Song } from "src/_gen/prisma-class/song";
|
||||
import { Genre as _Genre } from "src/_gen/prisma-class/genre";
|
||||
import { Artist as _Artist } from "src/_gen/prisma-class/artist";
|
||||
import { mapInclude } from "src/utils/include";
|
||||
import { SongController } from "src/song/song.controller";
|
||||
import { GenreController } from "src/genre/genre.controller";
|
||||
import { ArtistController } from "src/artist/artist.controller";
|
||||
|
||||
@ApiTags('search')
|
||||
@Controller('search')
|
||||
@ApiTags("search")
|
||||
@Controller("search")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SearchController {
|
||||
constructor(private readonly searchService: SearchService) { }
|
||||
constructor(private readonly searchService: SearchService) {}
|
||||
|
||||
@Get('songs/:query')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async searchSong(@Request() req: any, @Param('query') query: string): Promise<Song[] | null> {
|
||||
try {
|
||||
const ret = await this.searchService.songByGuess(query, req.user?.id);
|
||||
if (!ret.length) throw new NotFoundException();
|
||||
else return ret;
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
@Get("songs/:query")
|
||||
@ApiOkResponse({ type: _Song, isArray: true })
|
||||
@ApiOperation({ description: "Search a song" })
|
||||
@ApiUnauthorizedResponse({ description: "Invalid token" })
|
||||
async searchSong(
|
||||
@Request() req: any,
|
||||
@Param("query") query: string,
|
||||
@Query("artistId") artistId: number,
|
||||
@Query("genreId") genreId: number,
|
||||
@Query("include") include: string,
|
||||
@Query("skip", new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||
@Query("take", new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||
): Promise<Song[] | null> {
|
||||
return await this.searchService.searchSong(
|
||||
query,
|
||||
artistId,
|
||||
genreId,
|
||||
mapInclude(include, req, SongController.includableFields),
|
||||
skip,
|
||||
take,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('genres/:query')
|
||||
@Get("artists/:query")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async searchGenre(@Request() req: any, @Param('query') query: string): Promise<Genre[] | null> {
|
||||
try {
|
||||
const ret = await this.searchService.genreByGuess(query, req.user?.id);
|
||||
if (!ret.length) throw new NotFoundException();
|
||||
else return ret;
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
@ApiOkResponse({ type: _Artist, isArray: true })
|
||||
@ApiUnauthorizedResponse({ description: "Invalid token" })
|
||||
@ApiOperation({ description: "Search an artist" })
|
||||
async searchArtists(
|
||||
@Request() req: any,
|
||||
@Query("include") include: string,
|
||||
@Param("query") query: string,
|
||||
@Query("skip", new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||
@Query("take", new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||
): Promise<Artist[] | null> {
|
||||
return await this.searchService.searchArtists(
|
||||
query,
|
||||
mapInclude(include, req, ArtistController.includableFields),
|
||||
skip,
|
||||
take,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('artists/:query')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async searchArtists(@Request() req: any, @Param('query') query: string): Promise<Artist[] | null> {
|
||||
try {
|
||||
const ret = await this.searchService.artistByGuess(query, req.user?.id);
|
||||
if (!ret.length) throw new NotFoundException();
|
||||
else return ret;
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SearchService } from './search.service';
|
||||
import { SearchController } from './search.controller';
|
||||
import { HistoryModule } from 'src/history/history.module';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
import { SongService } from 'src/song/song.service';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SearchService } from "./search.service";
|
||||
import { SearchController } from "./search.controller";
|
||||
import { HistoryModule } from "src/history/history.module";
|
||||
import { PrismaModule } from "src/prisma/prisma.module";
|
||||
import { SongService } from "src/song/song.service";
|
||||
import { MeiliService } from "./meilisearch.service";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, HistoryModule],
|
||||
controllers: [SearchController],
|
||||
providers: [SearchService, SongService],
|
||||
exports: [SearchService],
|
||||
providers: [SearchService, SongService, MeiliService],
|
||||
exports: [SearchService, MeiliService],
|
||||
})
|
||||
export class SearchModule {}
|
||||
|
||||
@@ -1,33 +1,84 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Album, Artist, Prisma, Song, Genre } from '@prisma/client';
|
||||
import { HistoryService } from 'src/history/history.service';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Artist, Prisma, Song, Genre } from "@prisma/client";
|
||||
import { HistoryService } from "src/history/history.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { MeiliService } from "./meilisearch.service";
|
||||
|
||||
@Injectable()
|
||||
export class SearchService {
|
||||
constructor(private prisma: PrismaService, private history: HistoryService) { }
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private history: HistoryService,
|
||||
private search: MeiliService,
|
||||
) {}
|
||||
|
||||
async songByGuess(query: string, userID: number): Promise<Song[]> {
|
||||
return this.prisma.song.findMany({
|
||||
where: {
|
||||
name: { contains: query, mode: 'insensitive' },
|
||||
},
|
||||
});
|
||||
async searchSong(
|
||||
query: string,
|
||||
artistId?: number,
|
||||
genreId?: number,
|
||||
include?: Prisma.SongInclude,
|
||||
skip?: number,
|
||||
take?: number,
|
||||
): Promise<Song[]> {
|
||||
if (query.length === 0) {
|
||||
return await this.prisma.song.findMany({
|
||||
where: {
|
||||
artistId,
|
||||
genreId,
|
||||
},
|
||||
take,
|
||||
skip,
|
||||
include,
|
||||
});
|
||||
}
|
||||
const ids = (
|
||||
await this.search.index("songs").search(query, {
|
||||
limit: take,
|
||||
offset: skip,
|
||||
filter: [
|
||||
...(artistId ? [`artistId = ${artistId}`] : []),
|
||||
...(genreId ? [`genreId = ${genreId}`] : []),
|
||||
].join(" AND "),
|
||||
})
|
||||
).hits.map((x) => x.id);
|
||||
|
||||
return (
|
||||
await this.prisma.song.findMany({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
},
|
||||
include,
|
||||
})
|
||||
).sort((x) => ids.indexOf(x.id));
|
||||
}
|
||||
|
||||
async genreByGuess(query: string, userID: number): Promise<Genre[]> {
|
||||
return this.prisma.genre.findMany({
|
||||
where: {
|
||||
name: { contains: query, mode: 'insensitive' },
|
||||
},
|
||||
});
|
||||
}
|
||||
async searchArtists(
|
||||
query: string,
|
||||
include?: Prisma.ArtistInclude,
|
||||
skip?: number,
|
||||
take?: number,
|
||||
): Promise<Artist[]> {
|
||||
if (query.length === 0) {
|
||||
return this.prisma.artist.findMany({
|
||||
take,
|
||||
skip,
|
||||
include,
|
||||
});
|
||||
}
|
||||
const ids = (
|
||||
await this.search.index("artists").search(query, {
|
||||
limit: take,
|
||||
offset: skip,
|
||||
})
|
||||
).hits.map((x) => x.id);
|
||||
|
||||
async artistByGuess(query: string, userID: number): Promise<Artist[]> {
|
||||
return this.prisma.artist.findMany({
|
||||
where: {
|
||||
name: { contains: query, mode: 'insensitive' },
|
||||
},
|
||||
});
|
||||
return (
|
||||
await this.prisma.artist.findMany({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
},
|
||||
include,
|
||||
})
|
||||
).sort((x) => ids.indexOf(x.id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class UpdateSettingDto {
|
||||
@ApiProperty()
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SettingsService } from "./settings.service";
|
||||
import { PrismaModule } from "src/prisma/prisma.module";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [SettingsService],
|
||||
exports: [SettingsService],
|
||||
imports: [PrismaModule],
|
||||
providers: [SettingsService],
|
||||
exports: [SettingsService],
|
||||
})
|
||||
export class SettingsModule {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma, UserSettings } from '@prisma/client';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma, UserSettings } from "@prisma/client";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class SettingsService {
|
||||
@@ -20,10 +20,10 @@ export class SettingsService {
|
||||
user: {
|
||||
connect: {
|
||||
id: userId,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateUserSettings(params: {
|
||||
@@ -37,7 +37,9 @@ export class SettingsService {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUserSettings(where: Prisma.UserSettingsWhereUniqueInput): Promise<UserSettings> {
|
||||
async deleteUserSettings(
|
||||
where: Prisma.UserSettingsWhereUniqueInput,
|
||||
): Promise<UserSettings> {
|
||||
return this.prisma.userSettings.delete({
|
||||
where,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsNotEmpty } from "class-validator";
|
||||
|
||||
export class CreateSongDto {
|
||||
@IsNotEmpty()
|
||||
|
||||
@@ -15,55 +15,92 @@ import {
|
||||
Req,
|
||||
StreamableFile,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Plage } from 'src/models/plage';
|
||||
import { CreateSongDto } from './dto/create-song.dto';
|
||||
import { SongService } from './song.service';
|
||||
import { Request } from 'express';
|
||||
import { Prisma, Song } from '@prisma/client';
|
||||
import { createReadStream, existsSync } from 'fs';
|
||||
import { ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger';
|
||||
import { HistoryService } from 'src/history/history.service';
|
||||
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
|
||||
import { FilterQuery } from 'src/utils/filter.pipe';
|
||||
Header,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOkResponsePlaginated, Plage } from "src/models/plage";
|
||||
import { CreateSongDto } from "./dto/create-song.dto";
|
||||
import { SongService } from "./song.service";
|
||||
import { Request } from "express";
|
||||
import { Prisma, Song } from "@prisma/client";
|
||||
import { createReadStream, existsSync, readFileSync } from "fs";
|
||||
import {
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiProperty,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from "@nestjs/swagger";
|
||||
import { HistoryService } from "src/history/history.service";
|
||||
import { JwtAuthGuard } from "src/auth/jwt-auth.guard";
|
||||
import { FilterQuery } from "src/utils/filter.pipe";
|
||||
import { Song as _Song } from "src/_gen/prisma-class/song";
|
||||
import { SongHistory } from "src/_gen/prisma-class/song_history";
|
||||
import { IncludeMap, mapInclude } from "src/utils/include";
|
||||
import { Public } from "src/auth/public";
|
||||
import { ChromaAuthGuard } from "src/auth/chroma-auth.guard";
|
||||
class SongHistoryResult {
|
||||
@ApiProperty()
|
||||
best: number;
|
||||
@ApiProperty({ type: SongHistory, isArray: true })
|
||||
history: SongHistory[];
|
||||
}
|
||||
|
||||
@Controller('song')
|
||||
@ApiTags('song')
|
||||
@Controller("song")
|
||||
@ApiTags("song")
|
||||
@UseGuards(ChromaAuthGuard)
|
||||
export class SongController {
|
||||
static filterableFields: string[] = [
|
||||
'+id',
|
||||
'name',
|
||||
'+artistId',
|
||||
'+albumId',
|
||||
'+genreId',
|
||||
"+id",
|
||||
"name",
|
||||
"+artistId",
|
||||
"+albumId",
|
||||
"+genreId",
|
||||
];
|
||||
static includableFields: IncludeMap<Prisma.SongInclude> = {
|
||||
artist: true,
|
||||
album: true,
|
||||
genre: true,
|
||||
SongHistory: ({ user }) => ({ where: { userID: user.id } }),
|
||||
likedByUsers: ({ user }) => ({ where: { userId: user.id } }),
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly songService: SongService,
|
||||
private readonly historyService: HistoryService,
|
||||
) {}
|
||||
|
||||
@Get(':id/midi')
|
||||
async getMidi(@Param('id', ParseIntPipe) id: number) {
|
||||
@Get(":id/midi")
|
||||
@ApiOperation({ description: "Streams the midi file of the requested song" })
|
||||
@ApiNotFoundResponse({ description: "Song not found" })
|
||||
@ApiOkResponse({ description: "Returns the midi file succesfully" })
|
||||
async getMidi(@Param("id", ParseIntPipe) id: number) {
|
||||
const song = await this.songService.song({ id });
|
||||
if (!song) throw new NotFoundException('Song not found');
|
||||
if (!song) throw new NotFoundException("Song not found");
|
||||
|
||||
try {
|
||||
const file = createReadStream(song.midiPath);
|
||||
return new StreamableFile(file, { type: 'audio/midi' });
|
||||
return new StreamableFile(file, { type: "audio/midi" });
|
||||
} catch {
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
}
|
||||
|
||||
@Get(':id/illustration')
|
||||
async getIllustration(@Param('id', ParseIntPipe) id: number) {
|
||||
@Get(":id/illustration")
|
||||
@ApiOperation({
|
||||
description: "Streams the illustration of the requested song",
|
||||
})
|
||||
@ApiNotFoundResponse({ description: "Song not found" })
|
||||
@ApiOkResponse({ description: "Returns the illustration succesfully" })
|
||||
@Header("Cache-Control", "max-age=86400")
|
||||
@Public()
|
||||
async getIllustration(@Param("id", ParseIntPipe) id: number) {
|
||||
const song = await this.songService.song({ id });
|
||||
if (!song) throw new NotFoundException('Song not found');
|
||||
if (!song) throw new NotFoundException("Song not found");
|
||||
|
||||
if (song.illustrationPath === null) throw new NotFoundException();
|
||||
if (!existsSync(song.illustrationPath))
|
||||
throw new NotFoundException('Illustration not found');
|
||||
throw new NotFoundException("Illustration not found");
|
||||
|
||||
try {
|
||||
const file = createReadStream(song.illustrationPath);
|
||||
@@ -73,16 +110,78 @@ export class SongController {
|
||||
}
|
||||
}
|
||||
|
||||
@Get(':id/musicXml')
|
||||
async getMusicXml(@Param('id', ParseIntPipe) id: number) {
|
||||
@Get(":id/musicXml")
|
||||
@ApiOperation({
|
||||
description: "Streams the musicXML file of the requested song",
|
||||
})
|
||||
@ApiNotFoundResponse({ description: "Song not found" })
|
||||
@ApiOkResponse({ description: "Returns the musicXML file succesfully" })
|
||||
async getMusicXml(@Param("id", ParseIntPipe) id: number) {
|
||||
const song = await this.songService.song({ id });
|
||||
if (!song) throw new NotFoundException('Song not found');
|
||||
if (!song) throw new NotFoundException("Song not found");
|
||||
|
||||
const file = createReadStream(song.musicXmlPath, { encoding: 'binary' });
|
||||
const file = createReadStream(song.musicXmlPath, { encoding: "binary" });
|
||||
return new StreamableFile(file);
|
||||
}
|
||||
|
||||
@Get(":id/assets/partition")
|
||||
@ApiOperation({
|
||||
description: "Streams the svg partition of the requested song",
|
||||
})
|
||||
@ApiNotFoundResponse({ description: "Song not found" })
|
||||
@ApiOkResponse({ description: "Returns the svg partition succesfully" })
|
||||
@Header("Cache-Control", "max-age=86400")
|
||||
@Header("Content-Type", "image/svg+xml")
|
||||
@Public()
|
||||
async getPartition(@Param("id", ParseIntPipe) id: number) {
|
||||
const song = await this.songService.song({ id });
|
||||
if (!song) throw new NotFoundException("Song not found");
|
||||
|
||||
// check if /data/cache/songs/id exists
|
||||
if (!existsSync("/data/cache/songs/" + id + ".svg")) {
|
||||
// if not, generate assets
|
||||
await this.songService.createAssets(song.musicXmlPath, id);
|
||||
}
|
||||
|
||||
try {
|
||||
const file = readFileSync("/data/cache/songs/" + id + ".svg");
|
||||
return file.toString();
|
||||
} catch {
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
}
|
||||
|
||||
@Get(":id/assets/cursors")
|
||||
@ApiOperation({
|
||||
description: "Streams the partition cursors of the requested song",
|
||||
})
|
||||
@ApiNotFoundResponse({ description: "Song not found" })
|
||||
@ApiOkResponse({ description: "Returns the partition cursors succesfully" })
|
||||
@Header("Cache-Control", "max-age=86400")
|
||||
@Header("Content-Type", "application/json")
|
||||
async getCursors(@Param("id", ParseIntPipe) id: number) {
|
||||
const song = await this.songService.song({ id });
|
||||
if (!song) throw new NotFoundException("Song not found");
|
||||
|
||||
// check if /data/cache/songs/id exists
|
||||
if (!existsSync("/data/cache/songs/" + id + ".json")) {
|
||||
// if not, generate assets
|
||||
await this.songService.createAssets(song.musicXmlPath, id);
|
||||
}
|
||||
|
||||
try {
|
||||
const file = readFileSync("/data/cache/songs/" + id + ".json");
|
||||
return JSON.parse(file.toString());
|
||||
} catch {
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({
|
||||
description:
|
||||
"register a new song in the database, should not be used by the frontend",
|
||||
})
|
||||
async create(@Body() createSongDto: CreateSongDto) {
|
||||
try {
|
||||
return await this.songService.createSong({
|
||||
@@ -104,43 +203,66 @@ export class SongController {
|
||||
}
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@Param('id', ParseIntPipe) id: number) {
|
||||
@Delete(":id")
|
||||
@ApiOperation({ description: "delete a song by id" })
|
||||
async remove(@Param("id", ParseIntPipe) id: number) {
|
||||
try {
|
||||
return await this.songService.deleteSong({ id });
|
||||
} catch {
|
||||
throw new NotFoundException('Invalid ID');
|
||||
throw new NotFoundException("Invalid ID");
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOkResponsePlaginated(_Song)
|
||||
async findAll(
|
||||
@Req() req: Request,
|
||||
@FilterQuery(SongController.filterableFields) where: Prisma.SongWhereInput,
|
||||
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||
@Query("include") include: string,
|
||||
@Query("skip", new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||
@Query("take", new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||
): Promise<Plage<Song>> {
|
||||
const ret = await this.songService.songs({
|
||||
skip,
|
||||
take,
|
||||
where,
|
||||
include: mapInclude(include, req, SongController.includableFields),
|
||||
});
|
||||
return new Plage(ret, req);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
const res = await this.songService.song({ id });
|
||||
@Get(":id")
|
||||
@ApiOperation({ description: "Get a specific song data" })
|
||||
@ApiNotFoundResponse({ description: "Song not found" })
|
||||
@ApiOkResponse({ type: _Song, description: "Requested song" })
|
||||
async findOne(
|
||||
@Req() req: Request,
|
||||
@Param("id", ParseIntPipe) id: number,
|
||||
@Query("include") include: string,
|
||||
) {
|
||||
const res = await this.songService.song(
|
||||
{
|
||||
id,
|
||||
},
|
||||
mapInclude(include, req, SongController.includableFields),
|
||||
);
|
||||
|
||||
if (res === null) throw new NotFoundException('Song not found');
|
||||
if (res === null) throw new NotFoundException("Song not found");
|
||||
return res;
|
||||
}
|
||||
|
||||
@Get(':id/history')
|
||||
@Get(":id/history")
|
||||
@HttpCode(200)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
async getHistory(@Req() req: any, @Param('id', ParseIntPipe) id: number) {
|
||||
@ApiOperation({
|
||||
description: "get the history of the connected user on a specific song",
|
||||
})
|
||||
@ApiOkResponse({
|
||||
type: SongHistoryResult,
|
||||
description: "Records of previous games of the user",
|
||||
})
|
||||
@ApiUnauthorizedResponse({ description: "Invalid token" })
|
||||
async getHistory(@Req() req: any, @Param("id", ParseIntPipe) id: number) {
|
||||
return this.historyService.getForSong({
|
||||
playerId: req.user.id,
|
||||
songId: id,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SongService } from './song.service';
|
||||
import { SongController } from './song.controller';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
import { HistoryModule } from 'src/history/history.module';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SongService } from "./song.service";
|
||||
import { SongController } from "./song.controller";
|
||||
import { PrismaModule } from "src/prisma/prisma.module";
|
||||
import { HistoryModule } from "src/history/history.module";
|
||||
import { SearchModule } from "src/search/search.module";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, HistoryModule],
|
||||
imports: [PrismaModule, HistoryModule, SearchModule],
|
||||
providers: [SongService],
|
||||
controllers: [SongController],
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SongService } from './song.service';
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { SongService } from "./song.service";
|
||||
|
||||
describe('SongService', () => {
|
||||
describe("SongService", () => {
|
||||
let service: SongService;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -12,7 +12,7 @@ describe('SongService', () => {
|
||||
service = module.get<SongService>(SongService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,22 +1,65 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma, Song } from '@prisma/client';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma, Song } from "@prisma/client";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { MeiliService } from "src/search/meilisearch.service";
|
||||
import { generateSongAssets } from "src/assetsgenerator/generateImages_browserless";
|
||||
|
||||
@Injectable()
|
||||
export class SongService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
// number is the song id
|
||||
private assetCreationTasks: Map<number, Promise<void>>;
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private search: MeiliService,
|
||||
) {
|
||||
this.assetCreationTasks = new Map();
|
||||
}
|
||||
|
||||
async createAssets(mxlPath: string, songId: number): Promise<void> {
|
||||
if (this.assetCreationTasks.has(songId)) {
|
||||
await this.assetCreationTasks.get(songId);
|
||||
this.assetCreationTasks.delete(songId);
|
||||
return;
|
||||
}
|
||||
// mxlPath can the path to an archive to an xml file or the path to the xml file directly
|
||||
this.assetCreationTasks.set(
|
||||
songId,
|
||||
generateSongAssets(songId, mxlPath, "/data/cache/songs", "svg"),
|
||||
);
|
||||
return await this.assetCreationTasks.get(songId);
|
||||
}
|
||||
|
||||
async songByArtist(data: number): Promise<Song[]> {
|
||||
return this.prisma.song.findMany({
|
||||
where: {
|
||||
artistId: { equals: data },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createSong(data: Prisma.SongCreateInput): Promise<Song> {
|
||||
return this.prisma.song.create({
|
||||
const song = await this.prisma.song.create({
|
||||
data,
|
||||
});
|
||||
// Inculde the name of the artist in the song document to make search easier.
|
||||
const artist = song.artistId
|
||||
? await this.prisma.artist.findFirst({
|
||||
where: { id: song.artistId },
|
||||
})
|
||||
: null;
|
||||
await this.search
|
||||
.index("songs")
|
||||
.addDocuments([{ ...song, artist: artist?.name }]);
|
||||
return song;
|
||||
}
|
||||
|
||||
async song(
|
||||
songWhereUniqueInput: Prisma.SongWhereUniqueInput,
|
||||
include?: Prisma.SongInclude,
|
||||
): Promise<Song | null> {
|
||||
return this.prisma.song.findUnique({
|
||||
where: songWhereUniqueInput,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,20 +69,24 @@ export class SongService {
|
||||
cursor?: Prisma.SongWhereUniqueInput;
|
||||
where?: Prisma.SongWhereInput;
|
||||
orderBy?: Prisma.SongOrderByWithRelationInput;
|
||||
include?: Prisma.SongInclude;
|
||||
}): Promise<Song[]> {
|
||||
const { skip, take, cursor, where, orderBy } = params;
|
||||
const { skip, take, cursor, where, orderBy, include } = params;
|
||||
return this.prisma.song.findMany({
|
||||
skip,
|
||||
take,
|
||||
cursor,
|
||||
where,
|
||||
orderBy,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSong(where: Prisma.SongWhereUniqueInput): Promise<Song> {
|
||||
return this.prisma.song.delete({
|
||||
const ret = await this.prisma.song.delete({
|
||||
where,
|
||||
});
|
||||
await this.search.index("songs").deleteDocument(ret.id);
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class CreateUserDto {
|
||||
@ApiProperty()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateUserDto } from './create-user.dto';
|
||||
import { PartialType } from "@nestjs/mapped-types";
|
||||
import { CreateUserDto } from "./create-user.dto";
|
||||
|
||||
export class UpdateUserDto extends PartialType(CreateUserDto) {}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { Controller, Get, Param, NotFoundException } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import { ApiNotFoundResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { User } from 'src/models/user';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Param,
|
||||
NotFoundException,
|
||||
Response,
|
||||
} from "@nestjs/common";
|
||||
import { UsersService } from "./users.service";
|
||||
import { ApiNotFoundResponse, ApiOkResponse, ApiTags } from "@nestjs/swagger";
|
||||
import { User } from "src/models/user";
|
||||
|
||||
@ApiTags('users')
|
||||
@Controller('users')
|
||||
@ApiTags("users")
|
||||
@Controller("users")
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@@ -13,11 +20,19 @@ export class UsersController {
|
||||
return this.usersService.users({});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Get(":id")
|
||||
@ApiNotFoundResponse()
|
||||
async findOne(@Param('id') id: number): Promise<User> {
|
||||
async findOne(@Param("id") id: number): Promise<User> {
|
||||
const ret = await this.usersService.user({ id: +id });
|
||||
if (!ret) throw new NotFoundException();
|
||||
return ret;
|
||||
}
|
||||
|
||||
@Get(":id/picture")
|
||||
@ApiOkResponse({
|
||||
description: "Return the profile picture of the requested user",
|
||||
})
|
||||
async getPicture(@Response() res: any, @Param("id") id: number) {
|
||||
return await this.usersService.getProfilePicture(+id, res);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import { UsersController } from './users.controller';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
import { SettingsService } from 'src/settings/settings.service';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { UsersService } from "./users.service";
|
||||
import { UsersController } from "./users.controller";
|
||||
import { PrismaModule } from "src/prisma/prisma.module";
|
||||
import { SettingsService } from "src/settings/settings.service";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { User, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import {
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { User, Prisma } from "@prisma/client";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import * as bcrypt from "bcryptjs";
|
||||
import { createHash, randomUUID } from "crypto";
|
||||
import { createReadStream, existsSync } from "fs";
|
||||
import fetch from "node-fetch";
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
@@ -34,7 +40,7 @@ export class UsersService {
|
||||
}
|
||||
|
||||
async createUser(data: Prisma.UserCreateInput): Promise<User> {
|
||||
data.password = await bcrypt.hash(data.password, 8);
|
||||
if (data.password) data.password = await bcrypt.hash(data.password, 8);
|
||||
return this.prisma.user.create({
|
||||
data,
|
||||
});
|
||||
@@ -46,8 +52,8 @@ export class UsersService {
|
||||
username: `Guest ${randomUUID()}`,
|
||||
isGuest: true,
|
||||
// Not realyl clean but better than a separate table or breaking the api by adding nulls.
|
||||
email: '',
|
||||
password: '',
|
||||
email: null,
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -57,7 +63,7 @@ export class UsersService {
|
||||
data: Prisma.UserUpdateInput;
|
||||
}): Promise<User> {
|
||||
const { where, data } = params;
|
||||
if (typeof data.password === 'string')
|
||||
if (typeof data.password === "string")
|
||||
data.password = await bcrypt.hash(data.password, 8);
|
||||
else if (data.password && data.password.set)
|
||||
data.password = await bcrypt.hash(data.password.set, 8);
|
||||
@@ -72,4 +78,54 @@ export class UsersService {
|
||||
where,
|
||||
});
|
||||
}
|
||||
|
||||
async getProfilePicture(userId: number, res: any) {
|
||||
const path = `/data/${userId}.jpg`;
|
||||
if (existsSync(path)) {
|
||||
const file = createReadStream(path);
|
||||
return file.pipe(res);
|
||||
}
|
||||
// We could not find a profile icon locally, using gravatar instead.
|
||||
const user = await this.user({ id: userId });
|
||||
if (!user) throw new InternalServerErrorException();
|
||||
if (!user.email) throw new NotFoundException();
|
||||
const hash = createHash("md5")
|
||||
.update(user.email.trim().toLowerCase())
|
||||
.digest("hex");
|
||||
const resp = await fetch(
|
||||
`https://www.gravatar.com/avatar/${hash}.jpg?d=404&s=200`,
|
||||
);
|
||||
for (const [k, v] of resp.headers) resp.headers.set(k, v);
|
||||
resp.body!.pipe(res);
|
||||
}
|
||||
|
||||
async addLikedSong(userId: number, songId: number) {
|
||||
return this.prisma.likedSongs.create({
|
||||
data: { songId: songId, userId: userId },
|
||||
});
|
||||
}
|
||||
|
||||
async getLikedSongs(userId: number, include?: Prisma.SongInclude) {
|
||||
return this.prisma.likedSongs.findMany({
|
||||
where: { userId: userId },
|
||||
include: { song: include ? { include } : true },
|
||||
});
|
||||
}
|
||||
|
||||
async removeLikedSong(userId: number, songId: number) {
|
||||
return this.prisma.likedSongs.deleteMany({
|
||||
where: { userId: userId, songId: songId },
|
||||
});
|
||||
}
|
||||
|
||||
async addScore(where: number, score: number) {
|
||||
return this.prisma.user.update({
|
||||
where: { id: where },
|
||||
data: {
|
||||
partyPlayed: {
|
||||
increment: score,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
Injectable,
|
||||
PipeTransform,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
} from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class FilterPipe implements PipeTransform {
|
||||
@@ -12,13 +12,13 @@ export class FilterPipe implements PipeTransform {
|
||||
transform(value: Record<string, string>) {
|
||||
const filter = {};
|
||||
for (const fieldIdentifier of this.fields) {
|
||||
const field = fieldIdentifier.startsWith('+')
|
||||
const field = fieldIdentifier.startsWith("+")
|
||||
? fieldIdentifier.slice(1)
|
||||
: fieldIdentifier;
|
||||
|
||||
if (value[field] === undefined) continue;
|
||||
|
||||
if (fieldIdentifier.startsWith('+')) {
|
||||
if (fieldIdentifier.startsWith("+")) {
|
||||
filter[field] = parseInt(value[field]);
|
||||
if (isNaN(filter[field]))
|
||||
throw new BadRequestException(
|
||||
|
||||
33
back/src/utils/include.ts
Normal file
33
back/src/utils/include.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Request } from "express";
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
|
||||
export type IncludeMap<IncludeType> = {
|
||||
[key in keyof IncludeType]:
|
||||
| boolean
|
||||
| ((ctx: { user: { id: number; username: string } }) => IncludeType[key]);
|
||||
};
|
||||
|
||||
export function mapInclude<IncludeType>(
|
||||
include: string | undefined,
|
||||
req: Request,
|
||||
fields: IncludeMap<IncludeType>,
|
||||
): IncludeType | undefined {
|
||||
if (!include) return undefined;
|
||||
|
||||
const ret: IncludeType = {} as IncludeType;
|
||||
for (const key of include.split(",")) {
|
||||
const value =
|
||||
typeof fields[key] === "function"
|
||||
? fields[key]({ user: req.user })
|
||||
: fields[key];
|
||||
if (value !== false && value !== undefined) ret[key] = value;
|
||||
else {
|
||||
throw new BadRequestException(
|
||||
`Invalid include, ${key} is not valid. Valid includes are: ${Object.keys(
|
||||
fields,
|
||||
).join(", ")}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as request from 'supertest';
|
||||
import { AppModule } from './../src/app.module';
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import * as request from "supertest";
|
||||
import { AppModule } from "./../src/app.module";
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
describe("AppController (e2e)", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -15,10 +15,10 @@ describe('AppController (e2e)', () => {
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
it("/ (GET)", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.get("/")
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
.expect("Hello World!");
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user