Compare commits
221 Commits
redesign
...
feat/cover
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4a8eba699 | ||
| bfb6cf5958 | |||
| a92ca75760 | |||
| 8d8323e382 | |||
| 76d7e69d19 | |||
| be58e932a9 | |||
| 38bbe56e9b | |||
| a65ce6595a | |||
|
|
90f7890e5f | ||
|
|
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 | ||
|
|
e366fa4b32 | ||
|
|
cd87451208 | ||
|
|
845c473ed5 | ||
|
|
f4d75eef73 | ||
|
|
5395bbb03a | ||
|
|
2d90c6eec1 | ||
|
|
0b0fd0585d | ||
|
|
6cf72dfcca | ||
|
|
a81c0b83bb | ||
|
|
b2fb497ecf | ||
| 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_style = tab
|
||||||
indent_size = tab
|
indent_size = tab
|
||||||
|
|
||||||
[{*.yaml,*.yml}]
|
[{*.yaml,*.yml,*.nix}]
|
||||||
indent_style = space
|
indent_style = space
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
|
|||||||
10
.env.example
@@ -7,4 +7,12 @@ JWT_SECRET=wow
|
|||||||
POSTGRES_DB=chromacase
|
POSTGRES_DB=chromacase
|
||||||
API_URL=http://localhost:80/api
|
API_URL=http://localhost:80/api
|
||||||
SCORO_URL=ws://localhost:6543
|
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
|
||||||
|
|||||||
5
.envrc
@@ -1,4 +1 @@
|
|||||||
if ! has nix_direnv_version || ! nix_direnv_version 2.2.1; then
|
use nix
|
||||||
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/2.2.1/direnvrc" "sha256-zelF0vLbEl5uaqrfIzbgNzJWGmLzCmYAkInj/LNxvKs="
|
|
||||||
fi
|
|
||||||
use flake
|
|
||||||
|
|||||||
49
.github/workflows/CI.yml
vendored
@@ -27,6 +27,25 @@ jobs:
|
|||||||
|
|
||||||
## Build App ##
|
## Build App ##
|
||||||
|
|
||||||
|
Check_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
|
||||||
|
|
||||||
Build_Front:
|
Build_Front:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
@@ -42,13 +61,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn install
|
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
|
- name: 🏗 Setup Expo
|
||||||
uses: expo/expo-github-action@v7
|
uses: expo/expo-github-action@v7
|
||||||
@@ -56,6 +68,15 @@ jobs:
|
|||||||
expo-version: latest
|
expo-version: latest
|
||||||
eas-version: 3.3.1
|
eas-version: 3.3.1
|
||||||
token: ${{ secrets.EXPO_TOKEN }}
|
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
|
- name: Build Android APK
|
||||||
run: |
|
run: |
|
||||||
@@ -84,16 +105,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Copy env file to github secret env file
|
- name: Copy env file to github secret env file
|
||||||
run: |
|
run: cp .env.example .env
|
||||||
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
|
- name: Start the service
|
||||||
run: docker-compose up -d back db
|
run: docker-compose up -d back db
|
||||||
@@ -101,7 +113,8 @@ jobs:
|
|||||||
- name: Perform healthchecks
|
- name: Perform healthchecks
|
||||||
run: |
|
run: |
|
||||||
docker-compose ps -a
|
docker-compose ps -a
|
||||||
wget --retry-connrefused http://localhost:3000 # /healthcheck
|
docker-compose logs
|
||||||
|
wget --retry-connrefused http://localhost:3000 || (docker-compose logs && exit 1)
|
||||||
|
|
||||||
- name: Run scorometer tests
|
- name: Run scorometer tests
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
3
.gitignore
vendored
@@ -13,3 +13,6 @@ log.html
|
|||||||
node_modules/
|
node_modules/
|
||||||
./front/coverage
|
./front/coverage
|
||||||
.venv
|
.venv
|
||||||
|
.data
|
||||||
|
.DS_Store
|
||||||
|
_gen
|
||||||
|
|||||||
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.
|
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
|
After Width: | Height: | Size: 597 KiB |
@@ -1,9 +1,10 @@
|
|||||||
#!/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import requests
|
import requests
|
||||||
import glob
|
import glob
|
||||||
|
from mido import MidiFile
|
||||||
from configparser import ConfigParser
|
from configparser import ConfigParser
|
||||||
|
|
||||||
url = os.environ.get("API_URL")
|
url = os.environ.get("API_URL")
|
||||||
@@ -20,16 +21,16 @@ def getOrCreateAlbum(name, artistId):
|
|||||||
return out["id"]
|
return out["id"]
|
||||||
|
|
||||||
def getOrCreateGenre(names):
|
def getOrCreateGenre(names):
|
||||||
ids = []
|
ids = []
|
||||||
for name in names.split(","):
|
for name in names.split(","):
|
||||||
res = requests.post(f"{url}/genre", json={
|
res = requests.post(f"{url}/genre", json={
|
||||||
"name": name,
|
"name": name,
|
||||||
})
|
})
|
||||||
out = res.json()
|
out = res.json()
|
||||||
print(out)
|
print(out)
|
||||||
ids += [out["id"]]
|
ids += [out["id"]]
|
||||||
#TODO handle multiple genres
|
#TODO handle multiple genres
|
||||||
return ids[0]
|
return ids[0]
|
||||||
|
|
||||||
def getOrCreateArtist(name):
|
def getOrCreateArtist(name):
|
||||||
res = requests.post(f"{url}/artist", json={
|
res = requests.post(f"{url}/artist", json={
|
||||||
@@ -39,11 +40,19 @@ def getOrCreateArtist(name):
|
|||||||
print(out)
|
print(out)
|
||||||
return out["id"]
|
return out["id"]
|
||||||
|
|
||||||
|
def gen_cover():
|
||||||
|
|
||||||
def populateFile(path, midi, mxl):
|
def populateFile(path, midi, mxl):
|
||||||
config = ConfigParser()
|
config = ConfigParser()
|
||||||
config.read(path)
|
config.read(path)
|
||||||
|
mid = MidiFile(midi)
|
||||||
|
common = os.path.commonpath([midi, mxl])
|
||||||
|
png_path = f"{common}/illustration.png"
|
||||||
|
if not os.path.exists(png_path):
|
||||||
|
gen_cover(common)
|
||||||
metadata = config["Metadata"];
|
metadata = config["Metadata"];
|
||||||
difficulties = dict(config["Difficulties"])
|
difficulties = dict(config["Difficulties"])
|
||||||
|
difficulties["length"] = round((mid.length), 2)
|
||||||
artistId = getOrCreateArtist(metadata["Artist"])
|
artistId = getOrCreateArtist(metadata["Artist"])
|
||||||
print(f"Populating {metadata['Name']}")
|
print(f"Populating {metadata['Name']}")
|
||||||
res = requests.post(f"{url}/song", json={
|
res = requests.post(f"{url}/song", json={
|
||||||
@@ -54,11 +63,10 @@ def populateFile(path, midi, mxl):
|
|||||||
"artist": artistId,
|
"artist": artistId,
|
||||||
"album": getOrCreateAlbum(metadata["Album"], artistId),
|
"album": getOrCreateAlbum(metadata["Album"], artistId),
|
||||||
"genre": getOrCreateGenre(metadata["Genre"]),
|
"genre": getOrCreateGenre(metadata["Genre"]),
|
||||||
"illustrationPath": f"/assets/{os.path.commonpath([midi, mxl])}/illustration.png"
|
"illustrationPath": f"/assets/{png_path}"
|
||||||
})
|
})
|
||||||
print(res.json())
|
print(res.json())
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
global url
|
global url
|
||||||
if url == None:
|
if url == None:
|
||||||
|
|||||||
@@ -5,4 +5,4 @@ RUN npm install --frozen-lockfile
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
CMD npx prisma migrate dev; npm run start:prod
|
CMD npx prisma migrate deploy; npm run start:prod
|
||||||
|
|||||||
19482
back/package-lock.json
generated
@@ -10,8 +10,8 @@
|
|||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch --preserveWatchOutput",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch --preserveWatchOutput",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
@@ -21,51 +21,61 @@
|
|||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^8.0.0",
|
"@nestjs-modules/mailer": "^1.9.1",
|
||||||
"@nestjs/config": "^2.1.0",
|
"@nestjs/common": "^10.1.0",
|
||||||
"@nestjs/core": "^8.0.0",
|
"@nestjs/config": "^3.0.0",
|
||||||
"@nestjs/jwt": "^8.0.1",
|
"@nestjs/core": "^10.1.0",
|
||||||
|
"@nestjs/jwt": "^10.1.0",
|
||||||
"@nestjs/mapped-types": "*",
|
"@nestjs/mapped-types": "*",
|
||||||
"@nestjs/passport": "^8.2.2",
|
"@nestjs/passport": "^10.0.0",
|
||||||
"@nestjs/platform-express": "^8.0.0",
|
"@nestjs/platform-express": "^10.1.0",
|
||||||
"@nestjs/swagger": "^5.2.1",
|
"@nestjs/swagger": "^7.1.2",
|
||||||
"@prisma/client": "^4.4.0",
|
"@prisma/client": "^5.0.0",
|
||||||
"@types/bcrypt": "^5.0.0",
|
"@types/bcrypt": "^5.0.0",
|
||||||
"@types/bcryptjs": "^2.4.2",
|
"@types/bcryptjs": "^2.4.2",
|
||||||
"@types/passport": "^1.0.9",
|
"@types/passport": "^1.0.12",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.13.2",
|
"class-validator": "^0.13.2",
|
||||||
"passport-jwt": "^4.0.0",
|
"json-logger-service": "^9.0.1",
|
||||||
|
"class-validator": "^0.14.0",
|
||||||
|
"node-fetch": "^2.6.12",
|
||||||
|
"nodemailer": "^6.9.5",
|
||||||
|
"passport-google-oauth20": "^2.0.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
|
"prisma-class-generator": "^0.2.7",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^5.0.1",
|
||||||
"rxjs": "^7.2.0",
|
"rxjs": "^7.8.1",
|
||||||
"swagger-ui-express": "^4.5.0"
|
"swagger-ui-express": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^8.0.0",
|
"@nestjs/cli": "^10.1.10",
|
||||||
"@nestjs/schematics": "^8.0.0",
|
"@nestjs/schematics": "^10.0.1",
|
||||||
"@nestjs/testing": "^8.0.0",
|
"@nestjs/testing": "^10.1.0",
|
||||||
"@types/express": "^4.17.13",
|
"@types/express": "^4.17.17",
|
||||||
"@types/jest": "27.4.1",
|
"@types/jest": "29.5.3",
|
||||||
"@types/node": "^16.0.0",
|
"@types/multer": "^1.4.7",
|
||||||
"@types/supertest": "^2.0.11",
|
"@types/node": "^20.4.4",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
"@types/nodemailer": "^6.4.9",
|
||||||
"@typescript-eslint/parser": "^5.0.0",
|
"@types/passport-google-oauth20": "^2.0.11",
|
||||||
"eslint": "^8.0.1",
|
"@types/supertest": "^2.0.12",
|
||||||
"eslint-config-prettier": "^8.3.0",
|
"@typescript-eslint/eslint-plugin": "^6.1.0",
|
||||||
"eslint-plugin-prettier": "^4.0.0",
|
"@typescript-eslint/parser": "^6.1.0",
|
||||||
"jest": "^27.2.5",
|
"eslint": "^8.45.0",
|
||||||
"prettier": "^2.3.2",
|
"eslint-config-prettier": "^8.8.0",
|
||||||
"prisma": "^4.4.0",
|
"eslint-plugin-prettier": "^5.0.0",
|
||||||
"source-map-support": "^0.5.20",
|
"jest": "^29.6.1",
|
||||||
"supertest": "^6.1.3",
|
"prettier": "^3.0.0",
|
||||||
"ts-jest": "^27.0.3",
|
"prisma": "^5.0.0",
|
||||||
"ts-loader": "^9.2.3",
|
"source-map-support": "^0.5.21",
|
||||||
"ts-node": "^10.0.0",
|
"supertest": "^6.3.3",
|
||||||
"tsconfig-paths": "^3.10.1",
|
"ts-jest": "^29.1.1",
|
||||||
"typescript": "^4.3.5"
|
"ts-loader": "^9.4.4",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.1.6"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
|
|||||||
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
@@ -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
@@ -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
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ALTER COLUMN "email" DROP NOT NULL;
|
||||||
@@ -4,6 +4,12 @@ generator client {
|
|||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
generator prismaClassGenerator {
|
||||||
|
provider = "prisma-class-generator"
|
||||||
|
dryRun = false
|
||||||
|
separateRelationFields = true
|
||||||
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
@@ -12,14 +18,26 @@ datasource db {
|
|||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
username String @unique
|
username String @unique
|
||||||
password String
|
password String?
|
||||||
email String
|
email String? @unique
|
||||||
|
emailVerified Boolean @default(false)
|
||||||
|
googleID String? @unique
|
||||||
isGuest Boolean @default(false)
|
isGuest Boolean @default(false)
|
||||||
partyPlayed Int @default(0)
|
partyPlayed Int @default(0)
|
||||||
LessonHistory LessonHistory[]
|
LessonHistory LessonHistory[]
|
||||||
SongHistory SongHistory[]
|
SongHistory SongHistory[]
|
||||||
searchHistory SearchHistory[]
|
searchHistory SearchHistory[]
|
||||||
settings UserSettings?
|
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 {
|
model UserSettings {
|
||||||
@@ -59,6 +77,7 @@ model Song {
|
|||||||
genre Genre? @relation(fields: [genreId], references: [id])
|
genre Genre? @relation(fields: [genreId], references: [id])
|
||||||
difficulties Json
|
difficulties Json
|
||||||
SongHistory SongHistory[]
|
SongHistory SongHistory[]
|
||||||
|
likedByUsers LikedSongs[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model SongHistory {
|
model SongHistory {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
|
||||||
Body,
|
Body,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
Controller,
|
Controller,
|
||||||
@@ -12,23 +11,35 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Plage } from 'src/models/plage';
|
import { ApiOkResponsePlaginated, Plage } from 'src/models/plage';
|
||||||
import { CreateAlbumDto } from './dto/create-album.dto';
|
import { CreateAlbumDto } from './dto/create-album.dto';
|
||||||
import { AlbumService } from './album.service';
|
import { AlbumService } from './album.service';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { Prisma, Album } from '@prisma/client';
|
import { Prisma, Album } from '@prisma/client';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { FilterQuery } from 'src/utils/filter.pipe';
|
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 { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
|
||||||
|
|
||||||
@Controller('album')
|
@Controller('album')
|
||||||
@ApiTags('album')
|
@ApiTags('album')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
export class AlbumController {
|
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) {}
|
constructor(private readonly albumService: AlbumService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@ApiOperation({
|
||||||
|
description: 'Register a new album, should not be used by frontend',
|
||||||
|
})
|
||||||
async create(@Body() createAlbumDto: CreateAlbumDto) {
|
async create(@Body() createAlbumDto: CreateAlbumDto) {
|
||||||
try {
|
try {
|
||||||
return await this.albumService.createAlbum({
|
return await this.albumService.createAlbum({
|
||||||
@@ -45,6 +56,7 @@ export class AlbumController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@ApiOperation({ description: 'Delete an album by id' })
|
||||||
async remove(@Param('id', ParseIntPipe) id: number) {
|
async remove(@Param('id', ParseIntPipe) id: number) {
|
||||||
try {
|
try {
|
||||||
return await this.albumService.deleteAlbum({ id });
|
return await this.albumService.deleteAlbum({ id });
|
||||||
@@ -54,10 +66,13 @@ export class AlbumController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@ApiOkResponsePlaginated(_Album)
|
||||||
|
@ApiOperation({ description: 'Get all albums paginated' })
|
||||||
async findAll(
|
async findAll(
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@FilterQuery(AlbumController.filterableFields)
|
@FilterQuery(AlbumController.filterableFields)
|
||||||
where: Prisma.AlbumWhereInput,
|
where: Prisma.AlbumWhereInput,
|
||||||
|
@Query('include') include: string,
|
||||||
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||||
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
|
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||||
): Promise<Plage<Album>> {
|
): Promise<Plage<Album>> {
|
||||||
@@ -65,13 +80,23 @@ export class AlbumController {
|
|||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
where,
|
where,
|
||||||
|
include: mapInclude(include, req, AlbumController.includableFields),
|
||||||
});
|
});
|
||||||
return new Plage(ret, req);
|
return new Plage(ret, req);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async findOne(@Param('id', ParseIntPipe) id: number) {
|
@ApiOperation({ description: 'Get an album by id' })
|
||||||
const res = await this.albumService.album({ 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;
|
return res;
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ export class AlbumService {
|
|||||||
|
|
||||||
async album(
|
async album(
|
||||||
albumWhereUniqueInput: Prisma.AlbumWhereUniqueInput,
|
albumWhereUniqueInput: Prisma.AlbumWhereUniqueInput,
|
||||||
|
include?: Prisma.AlbumInclude,
|
||||||
): Promise<Album | null> {
|
): Promise<Album | null> {
|
||||||
return this.prisma.album.findUnique({
|
return this.prisma.album.findUnique({
|
||||||
where: albumWhereUniqueInput,
|
where: albumWhereUniqueInput,
|
||||||
|
include,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,14 +28,16 @@ export class AlbumService {
|
|||||||
cursor?: Prisma.AlbumWhereUniqueInput;
|
cursor?: Prisma.AlbumWhereUniqueInput;
|
||||||
where?: Prisma.AlbumWhereInput;
|
where?: Prisma.AlbumWhereInput;
|
||||||
orderBy?: Prisma.AlbumOrderByWithRelationInput;
|
orderBy?: Prisma.AlbumOrderByWithRelationInput;
|
||||||
|
include?: Prisma.AlbumInclude;
|
||||||
}): Promise<Album[]> {
|
}): Promise<Album[]> {
|
||||||
const { skip, take, cursor, where, orderBy } = params;
|
const { skip, take, cursor, where, orderBy, include } = params;
|
||||||
return this.prisma.album.findMany({
|
return this.prisma.album.findMany({
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
cursor,
|
cursor,
|
||||||
where,
|
where,
|
||||||
orderBy,
|
orderBy,
|
||||||
|
include,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
|
import { ApiOkResponse } from '@nestjs/swagger';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
constructor(private readonly appService: AppService) {}
|
constructor(private readonly appService: AppService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@ApiOkResponse({
|
||||||
|
description: 'Return a hello world message, used as a health route',
|
||||||
|
})
|
||||||
getHello(): string {
|
getHello(): string {
|
||||||
return this.appService.getHello();
|
return this.appService.getHello();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { ArtistModule } from './artist/artist.module';
|
|||||||
import { AlbumModule } from './album/album.module';
|
import { AlbumModule } from './album/album.module';
|
||||||
import { SearchModule } from './search/search.module';
|
import { SearchModule } from './search/search.module';
|
||||||
import { HistoryModule } from './history/history.module';
|
import { HistoryModule } from './history/history.module';
|
||||||
|
import { MailerModule } from '@nestjs-modules/mailer';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -28,6 +29,12 @@ import { HistoryModule } from './history/history.module';
|
|||||||
SearchModule,
|
SearchModule,
|
||||||
SettingsModule,
|
SettingsModule,
|
||||||
HistoryModule,
|
HistoryModule,
|
||||||
|
MailerModule.forRoot({
|
||||||
|
transport: process.env.SMTP_TRANSPORT,
|
||||||
|
defaults: {
|
||||||
|
from: process.env.MAIL_AUTHOR,
|
||||||
|
},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService, PrismaService, ArtistService],
|
providers: [AppService, PrismaService, ArtistService],
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
|
||||||
Body,
|
Body,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
Controller,
|
Controller,
|
||||||
@@ -14,24 +13,42 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
StreamableFile,
|
StreamableFile,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Plage } from 'src/models/plage';
|
import { ApiOkResponsePlaginated, Plage } from 'src/models/plage';
|
||||||
import { CreateArtistDto } from './dto/create-artist.dto';
|
import { CreateArtistDto } from './dto/create-artist.dto';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { ArtistService } from './artist.service';
|
import { ArtistService } from './artist.service';
|
||||||
import { Prisma, Artist } from '@prisma/client';
|
import { Prisma, Artist } from '@prisma/client';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiNotFoundResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import { createReadStream, existsSync } from 'fs';
|
import { createReadStream, existsSync } from 'fs';
|
||||||
import { FilterQuery } from 'src/utils/filter.pipe';
|
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 { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
|
||||||
|
import { Public } from 'src/auth/public';
|
||||||
|
|
||||||
@Controller('artist')
|
@Controller('artist')
|
||||||
@ApiTags('artist')
|
@ApiTags('artist')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
export class ArtistController {
|
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) {}
|
constructor(private readonly service: ArtistService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@ApiOperation({
|
||||||
|
description: 'Register a new artist, should not be used by frontend',
|
||||||
|
})
|
||||||
async create(@Body() dto: CreateArtistDto) {
|
async create(@Body() dto: CreateArtistDto) {
|
||||||
try {
|
try {
|
||||||
return await this.service.create(dto);
|
return await this.service.create(dto);
|
||||||
@@ -41,6 +58,7 @@ export class ArtistController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@ApiOperation({ description: 'Delete an artist by id' })
|
||||||
async remove(@Param('id', ParseIntPipe) id: number) {
|
async remove(@Param('id', ParseIntPipe) id: number) {
|
||||||
try {
|
try {
|
||||||
return await this.service.delete({ id });
|
return await this.service.delete({ id });
|
||||||
@@ -50,6 +68,9 @@ export class ArtistController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/illustration')
|
@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) {
|
async getIllustration(@Param('id', ParseIntPipe) id: number) {
|
||||||
const artist = await this.service.get({ id });
|
const artist = await this.service.get({ id });
|
||||||
if (!artist) throw new NotFoundException('Artist not found');
|
if (!artist) throw new NotFoundException('Artist not found');
|
||||||
@@ -66,10 +87,13 @@ export class ArtistController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@ApiOperation({ description: 'Get all artists paginated' })
|
||||||
|
@ApiOkResponsePlaginated(_Artist)
|
||||||
async findAll(
|
async findAll(
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@FilterQuery(ArtistController.filterableFields)
|
@FilterQuery(ArtistController.filterableFields)
|
||||||
where: Prisma.ArtistWhereInput,
|
where: Prisma.ArtistWhereInput,
|
||||||
|
@Query('include') include: string,
|
||||||
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||||
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
|
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||||
): Promise<Plage<Artist>> {
|
): Promise<Plage<Artist>> {
|
||||||
@@ -77,13 +101,23 @@ export class ArtistController {
|
|||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
where,
|
where,
|
||||||
|
include: mapInclude(include, req, ArtistController.includableFields),
|
||||||
});
|
});
|
||||||
return new Plage(ret, req);
|
return new Plage(ret, req);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async findOne(@Param('id', ParseIntPipe) id: number) {
|
@ApiOperation({ description: 'Get an artist by id' })
|
||||||
const res = await this.service.get({ 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;
|
return res;
|
||||||
|
|||||||
@@ -12,9 +12,13 @@ export class ArtistService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(where: Prisma.ArtistWhereUniqueInput): Promise<Artist | null> {
|
async get(
|
||||||
|
where: Prisma.ArtistWhereUniqueInput,
|
||||||
|
include?: Prisma.ArtistInclude,
|
||||||
|
): Promise<Artist | null> {
|
||||||
return this.prisma.artist.findUnique({
|
return this.prisma.artist.findUnique({
|
||||||
where,
|
where,
|
||||||
|
include,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,14 +28,16 @@ export class ArtistService {
|
|||||||
cursor?: Prisma.ArtistWhereUniqueInput;
|
cursor?: Prisma.ArtistWhereUniqueInput;
|
||||||
where?: Prisma.ArtistWhereInput;
|
where?: Prisma.ArtistWhereInput;
|
||||||
orderBy?: Prisma.ArtistOrderByWithRelationInput;
|
orderBy?: Prisma.ArtistOrderByWithRelationInput;
|
||||||
|
include?: Prisma.ArtistInclude;
|
||||||
}): Promise<Artist[]> {
|
}): Promise<Artist[]> {
|
||||||
const { skip, take, cursor, where, orderBy } = params;
|
const { skip, take, cursor, where, orderBy, include } = params;
|
||||||
return this.prisma.artist.findMany({
|
return this.prisma.artist.findMany({
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
cursor,
|
cursor,
|
||||||
where,
|
where,
|
||||||
orderBy,
|
orderBy,
|
||||||
|
include,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,20 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Delete,
|
Delete,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
Put,
|
Put,
|
||||||
InternalServerErrorException,
|
InternalServerErrorException,
|
||||||
Patch,
|
Patch,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
Req,
|
||||||
|
UseInterceptors,
|
||||||
|
UploadedFile,
|
||||||
|
HttpStatus,
|
||||||
|
ParseFilePipeBuilder,
|
||||||
|
Response,
|
||||||
|
Query,
|
||||||
|
Param,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
@@ -19,9 +28,12 @@ import { LocalAuthGuard } from './local-auth.guard';
|
|||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { UsersService } from 'src/users/users.service';
|
import { UsersService } from 'src/users/users.service';
|
||||||
import {
|
import {
|
||||||
|
ApiBadRequestResponse,
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiBody,
|
ApiBody,
|
||||||
|
ApiConflictResponse,
|
||||||
ApiOkResponse,
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiUnauthorizedResponse,
|
ApiUnauthorizedResponse,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
@@ -32,6 +44,10 @@ import { Profile } from './dto/profile.dto';
|
|||||||
import { Setting } from 'src/models/setting';
|
import { Setting } from 'src/models/setting';
|
||||||
import { UpdateSettingDto } from 'src/settings/dto/update-setting.dto';
|
import { UpdateSettingDto } from 'src/settings/dto/update-setting.dto';
|
||||||
import { SettingsService } from 'src/settings/settings.service';
|
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 ';
|
||||||
|
|
||||||
@ApiTags('auth')
|
@ApiTags('auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@@ -42,38 +58,155 @@ export class AuthController {
|
|||||||
private settingsService: SettingsService,
|
private settingsService: SettingsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@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')
|
@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> {
|
async register(@Body() registerDto: RegisterDto): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const user = await this.usersService.createUser(registerDto)
|
const user = await this.usersService.createUser(registerDto);
|
||||||
await this.settingsService.createUserSetting(user.id);
|
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);
|
console.error(e);
|
||||||
throw new BadRequestException();
|
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 })
|
@ApiBody({ type: LoginDto })
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@UseGuards(LocalAuthGuard)
|
@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> {
|
async login(@Request() req: any): Promise<JwtToken> {
|
||||||
return this.authService.login(req.user);
|
return this.authService.login(req.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(200)
|
|
||||||
@Post('guest')
|
@Post('guest')
|
||||||
|
@HttpCode(200)
|
||||||
|
@ApiOperation({ description: 'Login as a guest account' })
|
||||||
|
@ApiOkResponse({ description: 'Successfully logged in', type: JwtToken })
|
||||||
async guest(): Promise<JwtToken> {
|
async guest(): Promise<JwtToken> {
|
||||||
const user = await this.usersService.createGuest();
|
const user = await this.usersService.createGuest();
|
||||||
await this.settingsService.createUserSetting(user.id);
|
await this.settingsService.createUserSetting(user.id);
|
||||||
return this.authService.login(user);
|
return this.authService.login(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@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(JwtAuthGuard)
|
||||||
|
@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)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOkResponse({ description: 'Successfully logged in', type: User })
|
@ApiOkResponse({ description: 'Successfully logged in', type: User })
|
||||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||||
@Get('me')
|
@Get('me')
|
||||||
|
@ApiOperation({ description: 'Get the user info of connected user' })
|
||||||
async getProfile(@Request() req: any): Promise<User> {
|
async getProfile(@Request() req: any): Promise<User> {
|
||||||
const user = await this.usersService.user({ id: req.user.id });
|
const user = await this.usersService.user({ id: req.user.id });
|
||||||
if (!user) throw new InternalServerErrorException();
|
if (!user) throw new InternalServerErrorException();
|
||||||
@@ -85,6 +218,7 @@ export class AuthController {
|
|||||||
@ApiOkResponse({ description: 'Successfully edited profile', type: User })
|
@ApiOkResponse({ description: 'Successfully edited profile', type: User })
|
||||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||||
@Put('me')
|
@Put('me')
|
||||||
|
@ApiOperation({ description: 'Edit the profile of connected user' })
|
||||||
editProfile(
|
editProfile(
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@Body() profile: Partial<Profile>,
|
@Body() profile: Partial<Profile>,
|
||||||
@@ -110,32 +244,65 @@ export class AuthController {
|
|||||||
@ApiOkResponse({ description: 'Successfully deleted', type: User })
|
@ApiOkResponse({ description: 'Successfully deleted', type: User })
|
||||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||||
@Delete('me')
|
@Delete('me')
|
||||||
|
@ApiOperation({ description: 'Delete the profile of connected user' })
|
||||||
deleteSelf(@Request() req: any): Promise<User> {
|
deleteSelf(@Request() req: any): Promise<User> {
|
||||||
return this.usersService.deleteUser({ id: req.user.id });
|
return this.usersService.deleteUser({ id: req.user.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOkResponse({description: 'Successfully edited settings', type: Setting})
|
@ApiOkResponse({ description: 'Successfully edited settings', type: Setting })
|
||||||
@ApiUnauthorizedResponse({description: 'Invalid token'})
|
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||||
@Patch('me/settings')
|
@Patch('me/settings')
|
||||||
|
@ApiOperation({ description: 'Edit the settings of connected user' })
|
||||||
udpateSettings(
|
udpateSettings(
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@Body() settingUserDto: UpdateSettingDto): Promise<Setting> {
|
@Body() settingUserDto: UpdateSettingDto,
|
||||||
|
): Promise<Setting> {
|
||||||
return this.settingsService.updateUserSettings({
|
return this.settingsService.updateUserSettings({
|
||||||
where: { userId: +req.user.id},
|
where: { userId: +req.user.id },
|
||||||
data: settingUserDto,
|
data: settingUserDto,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOkResponse({description: 'Successfully edited settings', type: Setting})
|
@ApiOkResponse({ description: 'Successfully edited settings', type: Setting })
|
||||||
@ApiUnauthorizedResponse({description: 'Invalid token'})
|
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||||
@Get('me/settings')
|
@Get('me/settings')
|
||||||
|
@ApiOperation({ description: 'Get the settings of connected user' })
|
||||||
async getSettings(@Request() req: any): Promise<Setting> {
|
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();
|
if (!result) throw new NotFoundException();
|
||||||
return result;
|
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) {
|
||||||
|
return this.usersService.getLikedSongs(+req.user.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ConfigModule } from '@nestjs/config';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { JwtStrategy } from './jwt.strategy';
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
import { SettingsModule } from 'src/settings/settings.module';
|
import { SettingsModule } from 'src/settings/settings.module';
|
||||||
|
import { GoogleStrategy } from './google.strategy';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -20,12 +21,12 @@ import { SettingsModule } from 'src/settings/settings.module';
|
|||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
useFactory: async (configService: ConfigService) => ({
|
useFactory: async (configService: ConfigService) => ({
|
||||||
secret: configService.get('JWT_SECRET'),
|
secret: configService.get('JWT_SECRET'),
|
||||||
signOptions: { expiresIn: '1h' },
|
signOptions: { expiresIn: '365d' },
|
||||||
}),
|
}),
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
providers: [AuthService, LocalStrategy, JwtStrategy],
|
providers: [AuthService, LocalStrategy, JwtStrategy, GoogleStrategy],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
import PayloadInterface from './interface/payload.interface';
|
import PayloadInterface from './interface/payload.interface';
|
||||||
|
import { User } from 'src/models/user';
|
||||||
|
import { MailerService } from '@nestjs-modules/mailer';
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
constructor(
|
constructor(
|
||||||
private userService: UsersService,
|
private userService: UsersService,
|
||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
|
private emailService: MailerService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async validateUser(
|
async validateUser(
|
||||||
@@ -15,7 +18,7 @@ export class AuthService {
|
|||||||
password: string,
|
password: string,
|
||||||
): Promise<PayloadInterface | null> {
|
): Promise<PayloadInterface | null> {
|
||||||
const user = await this.userService.user({ username });
|
const user = await this.userService.user({ username });
|
||||||
if (user && bcrypt.compareSync(password, user.password)) {
|
if (user && user.password && bcrypt.compareSync(password, user.password)) {
|
||||||
return {
|
return {
|
||||||
username: user.username,
|
username: user.username,
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -31,4 +34,70 @@ export class AuthService {
|
|||||||
access_token,
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
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,24 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { IS_PUBLIC_KEY } from './public';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||||
|
constructor(private reflector: Reflector) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext) {
|
||||||
|
console.log(context);
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
console.log(isPublic);
|
||||||
|
if (isPublic) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return super.canActivate(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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);
|
||||||
@@ -13,8 +13,9 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
StreamableFile,
|
StreamableFile,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Plage } from 'src/models/plage';
|
import { ApiOkResponsePlaginated, Plage } from 'src/models/plage';
|
||||||
import { CreateGenreDto } from './dto/create-genre.dto';
|
import { CreateGenreDto } from './dto/create-genre.dto';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { GenreService } from './genre.service';
|
import { GenreService } from './genre.service';
|
||||||
@@ -22,11 +23,19 @@ import { Prisma, Genre } from '@prisma/client';
|
|||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { createReadStream, existsSync } from 'fs';
|
import { createReadStream, existsSync } from 'fs';
|
||||||
import { FilterQuery } from 'src/utils/filter.pipe';
|
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 { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
|
||||||
|
import { Public } from 'src/auth/public';
|
||||||
|
|
||||||
@Controller('genre')
|
@Controller('genre')
|
||||||
@ApiTags('genre')
|
@ApiTags('genre')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
export class GenreController {
|
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) {}
|
constructor(private readonly service: GenreService) {}
|
||||||
|
|
||||||
@@ -49,6 +58,7 @@ export class GenreController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/illustration')
|
@Get(':id/illustration')
|
||||||
|
@Public()
|
||||||
async getIllustration(@Param('id', ParseIntPipe) id: number) {
|
async getIllustration(@Param('id', ParseIntPipe) id: number) {
|
||||||
const genre = await this.service.get({ id });
|
const genre = await this.service.get({ id });
|
||||||
if (!genre) throw new NotFoundException('Genre not found');
|
if (!genre) throw new NotFoundException('Genre not found');
|
||||||
@@ -65,10 +75,12 @@ export class GenreController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@ApiOkResponsePlaginated(_Genre)
|
||||||
async findAll(
|
async findAll(
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@FilterQuery(GenreController.filterableFields)
|
@FilterQuery(GenreController.filterableFields)
|
||||||
where: Prisma.GenreWhereInput,
|
where: Prisma.GenreWhereInput,
|
||||||
|
@Query('include') include: string,
|
||||||
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||||
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
|
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||||
): Promise<Plage<Genre>> {
|
): Promise<Plage<Genre>> {
|
||||||
@@ -76,13 +88,21 @@ export class GenreController {
|
|||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
where,
|
where,
|
||||||
|
include: mapInclude(include, req, GenreController.includableFields),
|
||||||
});
|
});
|
||||||
return new Plage(ret, req);
|
return new Plage(ret, req);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async findOne(@Param('id', ParseIntPipe) id: number) {
|
async findOne(
|
||||||
const res = await this.service.get({ id });
|
@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;
|
return res;
|
||||||
|
|||||||
@@ -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({
|
return this.prisma.genre.findUnique({
|
||||||
where,
|
where,
|
||||||
|
include,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,14 +28,16 @@ export class GenreService {
|
|||||||
cursor?: Prisma.GenreWhereUniqueInput;
|
cursor?: Prisma.GenreWhereUniqueInput;
|
||||||
where?: Prisma.GenreWhereInput;
|
where?: Prisma.GenreWhereInput;
|
||||||
orderBy?: Prisma.GenreOrderByWithRelationInput;
|
orderBy?: Prisma.GenreOrderByWithRelationInput;
|
||||||
|
include?: Prisma.GenreInclude;
|
||||||
}): Promise<Genre[]> {
|
}): Promise<Genre[]> {
|
||||||
const { skip, take, cursor, where, orderBy } = params;
|
const { skip, take, cursor, where, orderBy, include } = params;
|
||||||
return this.prisma.genre.findMany({
|
return this.prisma.genre.findMany({
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
cursor,
|
cursor,
|
||||||
where,
|
where,
|
||||||
orderBy,
|
orderBy,
|
||||||
|
include,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class SearchHistoryDto {
|
export class SearchHistoryDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
query: string;
|
query: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
type: "song" | "artist" | "album" | "genre";
|
type: 'song' | 'artist' | 'album' | 'genre';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsNumber } from "class-validator";
|
import { IsNumber } from 'class-validator';
|
||||||
|
|
||||||
export class SongHistoryDto {
|
export class SongHistoryDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -15,8 +15,8 @@ export class SongHistoryDto {
|
|||||||
score: number;
|
score: number;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
difficulties: Record<string, number>
|
difficulties: Record<string, number>;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
info: Record<string, number>
|
info: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,21 +10,31 @@ import {
|
|||||||
Request,
|
Request,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiCreatedResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
ApiUnauthorizedResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import { SearchHistory, SongHistory } from '@prisma/client';
|
import { SearchHistory, SongHistory } from '@prisma/client';
|
||||||
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
|
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
|
||||||
import { SongHistoryDto } from './dto/SongHistoryDto';
|
import { SongHistoryDto } from './dto/SongHistoryDto';
|
||||||
import { HistoryService } from './history.service';
|
import { HistoryService } from './history.service';
|
||||||
import { SearchHistoryDto } from './dto/SearchHistoryDto';
|
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';
|
||||||
|
|
||||||
@Controller('history')
|
@Controller('history')
|
||||||
@ApiTags('history')
|
@ApiTags('history')
|
||||||
export class HistoryController {
|
export class HistoryController {
|
||||||
constructor(private readonly historyService: HistoryService) { }
|
constructor(private readonly historyService: HistoryService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
|
@ApiOperation({ description: 'Get song history of connected user' })
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiOkResponse({ type: _SongHistory, isArray: true })
|
||||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||||
async getHistory(
|
async getHistory(
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@@ -36,7 +46,9 @@ export class HistoryController {
|
|||||||
|
|
||||||
@Get('search')
|
@Get('search')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
|
@ApiOperation({ description: 'Get search history of connected user' })
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiOkResponse({ type: _SearchHistory, isArray: true })
|
||||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||||
async getSearchHistory(
|
async getSearchHistory(
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@@ -48,18 +60,24 @@ export class HistoryController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@HttpCode(201)
|
@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> {
|
async create(@Body() record: SongHistoryDto): Promise<SongHistory> {
|
||||||
return this.historyService.createSongHistoryRecord(record);
|
return this.historyService.createSongHistoryRecord(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("search")
|
@Post('search')
|
||||||
@HttpCode(201)
|
@HttpCode(201)
|
||||||
|
@ApiOperation({ description: 'Creates a search record in the users history' })
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiUnauthorizedResponse({description: "Invalid token"})
|
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||||
async createSearchHistory(
|
async createSearchHistory(
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@Body() record: SearchHistoryDto
|
@Body() record: SearchHistoryDto,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.historyService.createSearchHistoryRecord(req.user.id, { query: record.query, type: record.type });
|
await this.historyService.createSearchHistoryRecord(req.user.id, {
|
||||||
}
|
query: record.query,
|
||||||
|
type: record.type,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,17 @@ import { Test, TestingModule } from '@nestjs/testing';
|
|||||||
import { HistoryService } from './history.service';
|
import { HistoryService } from './history.service';
|
||||||
|
|
||||||
describe('HistoryService', () => {
|
describe('HistoryService', () => {
|
||||||
let service: HistoryService;
|
let service: HistoryService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [HistoryService],
|
providers: [HistoryService],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<HistoryService>(HistoryService);
|
service = module.get<HistoryService>(HistoryService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(service).toBeDefined();
|
expect(service).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { SongHistoryDto } from './dto/SongHistoryDto';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class HistoryService {
|
export class HistoryService {
|
||||||
constructor(private prisma: PrismaService) { }
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
async createSongHistoryRecord({
|
async createSongHistoryRecord({
|
||||||
songID,
|
songID,
|
||||||
@@ -74,7 +74,7 @@ export class HistoryService {
|
|||||||
|
|
||||||
async createSearchHistoryRecord(
|
async createSearchHistoryRecord(
|
||||||
userID: number,
|
userID: number,
|
||||||
{ query, type }: SearchHistoryDto
|
{ query, type }: SearchHistoryDto,
|
||||||
): Promise<SearchHistory> {
|
): Promise<SearchHistory> {
|
||||||
return this.prisma.searchHistory.create({
|
return this.prisma.searchHistory.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
Request,
|
|
||||||
Param,
|
Param,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
DefaultValuePipe,
|
DefaultValuePipe,
|
||||||
@@ -12,12 +11,17 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Delete,
|
Delete,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Plage } from 'src/models/plage';
|
import { ApiOkResponsePlaginated, Plage } from 'src/models/plage';
|
||||||
import { LessonService } from './lesson.service';
|
import { LessonService } from './lesson.service';
|
||||||
import { ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger';
|
||||||
import { Prisma, Skill } from '@prisma/client';
|
import { Prisma, Skill } from '@prisma/client';
|
||||||
import { FilterQuery } from 'src/utils/filter.pipe';
|
import { FilterQuery } from 'src/utils/filter.pipe';
|
||||||
|
import { Lesson as _Lesson } from 'src/_gen/prisma-class/lesson';
|
||||||
|
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
|
||||||
|
import { IncludeMap, mapInclude } from 'src/utils/include';
|
||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
export class Lesson {
|
export class Lesson {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -34,6 +38,7 @@ export class Lesson {
|
|||||||
|
|
||||||
@ApiTags('lessons')
|
@ApiTags('lessons')
|
||||||
@Controller('lesson')
|
@Controller('lesson')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
export class LessonController {
|
export class LessonController {
|
||||||
static filterableFields: string[] = [
|
static filterableFields: string[] = [
|
||||||
'+id',
|
'+id',
|
||||||
@@ -41,6 +46,9 @@ export class LessonController {
|
|||||||
'+requiredLevel',
|
'+requiredLevel',
|
||||||
'mainSkill',
|
'mainSkill',
|
||||||
];
|
];
|
||||||
|
static includableFields: IncludeMap<Prisma.LessonInclude> = {
|
||||||
|
LessonHistory: true,
|
||||||
|
};
|
||||||
|
|
||||||
constructor(private lessonService: LessonService) {}
|
constructor(private lessonService: LessonService) {}
|
||||||
|
|
||||||
@@ -48,10 +56,12 @@ export class LessonController {
|
|||||||
summary: 'Get all lessons',
|
summary: 'Get all lessons',
|
||||||
})
|
})
|
||||||
@Get()
|
@Get()
|
||||||
|
@ApiOkResponsePlaginated(_Lesson)
|
||||||
async getAll(
|
async getAll(
|
||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
@FilterQuery(LessonController.filterableFields)
|
@FilterQuery(LessonController.filterableFields)
|
||||||
where: Prisma.LessonWhereInput,
|
where: Prisma.LessonWhereInput,
|
||||||
|
@Query('include') include: string,
|
||||||
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||||
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
|
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||||
): Promise<Plage<Lesson>> {
|
): Promise<Plage<Lesson>> {
|
||||||
@@ -59,6 +69,7 @@ export class LessonController {
|
|||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
where,
|
where,
|
||||||
|
include: mapInclude(include, request, LessonController.includableFields),
|
||||||
});
|
});
|
||||||
return new Plage(ret, request);
|
return new Plage(ret, request);
|
||||||
}
|
}
|
||||||
@@ -67,8 +78,15 @@ export class LessonController {
|
|||||||
summary: 'Get a particular lessons',
|
summary: 'Get a particular lessons',
|
||||||
})
|
})
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async get(@Param('id', ParseIntPipe) id: number): Promise<Lesson> {
|
async get(
|
||||||
const ret = await this.lessonService.get(id);
|
@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();
|
if (!ret) throw new NotFoundException();
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,22 +12,28 @@ export class LessonService {
|
|||||||
cursor?: Prisma.LessonWhereUniqueInput;
|
cursor?: Prisma.LessonWhereUniqueInput;
|
||||||
where?: Prisma.LessonWhereInput;
|
where?: Prisma.LessonWhereInput;
|
||||||
orderBy?: Prisma.LessonOrderByWithRelationInput;
|
orderBy?: Prisma.LessonOrderByWithRelationInput;
|
||||||
|
include?: Prisma.LessonInclude;
|
||||||
}): Promise<Lesson[]> {
|
}): Promise<Lesson[]> {
|
||||||
const { skip, take, cursor, where, orderBy } = params;
|
const { skip, take, cursor, where, orderBy, include } = params;
|
||||||
return this.prisma.lesson.findMany({
|
return this.prisma.lesson.findMany({
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
cursor,
|
cursor,
|
||||||
where,
|
where,
|
||||||
orderBy,
|
orderBy,
|
||||||
|
include,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(id: number): Promise<Lesson | null> {
|
async get(
|
||||||
|
id: number,
|
||||||
|
include?: Prisma.LessonInclude,
|
||||||
|
): Promise<Lesson | null> {
|
||||||
return this.prisma.lesson.findFirst({
|
return this.prisma.lesson.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: id,
|
id: id,
|
||||||
},
|
},
|
||||||
|
include,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,72 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { PrismaService } from './prisma/prisma.service';
|
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
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() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
const prismaService = app.get(PrismaService);
|
app.use(
|
||||||
await prismaService.enableShutdownHooks(app);
|
RequestLogger.buildExpressRequestLogger({
|
||||||
|
doNotLogPaths: ['/health'],
|
||||||
|
} as RequestLoggerOptions),
|
||||||
|
);
|
||||||
|
app.enableShutdownHooks();
|
||||||
|
|
||||||
const config = new DocumentBuilder()
|
const config = new DocumentBuilder()
|
||||||
.setTitle('Chromacase')
|
.setTitle('Chromacase')
|
||||||
.setDescription('The chromacase API')
|
.setDescription('The chromacase API')
|
||||||
.setVersion('1.0')
|
.setVersion('1.0')
|
||||||
.build();
|
.build();
|
||||||
const document = SwaggerModule.createDocument(app, config);
|
const document = SwaggerModule.createDocument(app, config, {
|
||||||
|
extraModels: [...PrismaModel.extraModels],
|
||||||
|
});
|
||||||
SwaggerModule.setup('api', app, document);
|
SwaggerModule.setup('api', app, document);
|
||||||
|
|
||||||
app.useGlobalPipes(new ValidationPipe());
|
app.useGlobalPipes(new ValidationPipe());
|
||||||
app.enableCors();
|
app.enableCors();
|
||||||
|
app.useGlobalInterceptors(new AspectLogger());
|
||||||
|
|
||||||
await app.listen(3000);
|
await app.listen(3000);
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -2,16 +2,35 @@
|
|||||||
* Thanks to https://github.com/Arthi-chaud/Meelo/blob/master/src/pagination/models/paginated-response.ts
|
* 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()
|
@ApiProperty()
|
||||||
metadata: {
|
this: string;
|
||||||
this: string;
|
@ApiProperty({
|
||||||
next: string | null;
|
type: 'string',
|
||||||
previous: string | null;
|
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()
|
@ApiProperty()
|
||||||
|
metadata: PlageMetadata;
|
||||||
data: T[];
|
data: T[];
|
||||||
|
|
||||||
constructor(data: T[], request: Request | any) {
|
constructor(data: T[], request: Request | any) {
|
||||||
@@ -49,3 +68,25 @@ export class Plage<T> {
|
|||||||
return route;
|
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) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export class User {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
username: string;
|
username: string;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
email: string;
|
email: string | null;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
isGuest: boolean;
|
isGuest: boolean;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common';
|
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -6,10 +6,4 @@ export class PrismaService extends PrismaClient implements OnModuleInit {
|
|||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
await this.$connect();
|
await this.$connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
async enableShutdownHooks(app: INestApplication) {
|
|
||||||
this.$on('beforeExit', async () => {
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,51 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
|
||||||
Body,
|
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
|
||||||
InternalServerErrorException,
|
InternalServerErrorException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
ParseIntPipe,
|
Query,
|
||||||
Post,
|
|
||||||
Request,
|
Request,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
ApiUnauthorizedResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import { Artist, Genre, Song } from '@prisma/client';
|
import { Artist, Genre, Song } from '@prisma/client';
|
||||||
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
|
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
|
||||||
import { SearchSongDto } from './dto/search-song.dto';
|
|
||||||
import { SearchService } from './search.service';
|
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')
|
@ApiTags('search')
|
||||||
@Controller('search')
|
@Controller('search')
|
||||||
export class SearchController {
|
export class SearchController {
|
||||||
constructor(private readonly searchService: SearchService) { }
|
constructor(private readonly searchService: SearchService) {}
|
||||||
|
|
||||||
@Get('songs/:query')
|
@Get('songs/:query')
|
||||||
|
@ApiOkResponse({ type: _Song, isArray: true })
|
||||||
|
@ApiOperation({ description: 'Search a song' })
|
||||||
|
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
async searchSong(@Request() req: any, @Param('query') query: string): Promise<Song[] | null> {
|
async searchSong(
|
||||||
|
@Request() req: any,
|
||||||
|
@Query('include') include: string,
|
||||||
|
@Param('query') query: string,
|
||||||
|
): Promise<Song[] | null> {
|
||||||
try {
|
try {
|
||||||
const ret = await this.searchService.songByGuess(query, req.user?.id);
|
const ret = await this.searchService.songByGuess(
|
||||||
|
query,
|
||||||
|
req.user?.id,
|
||||||
|
mapInclude(include, req, SongController.includableFields),
|
||||||
|
);
|
||||||
if (!ret.length) throw new NotFoundException();
|
if (!ret.length) throw new NotFoundException();
|
||||||
else return ret;
|
else return ret;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -37,9 +55,20 @@ export class SearchController {
|
|||||||
|
|
||||||
@Get('genres/:query')
|
@Get('genres/:query')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
async searchGenre(@Request() req: any, @Param('query') query: string): Promise<Genre[] | null> {
|
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||||
|
@ApiOkResponse({ type: _Genre, isArray: true })
|
||||||
|
@ApiOperation({ description: 'Search a genre' })
|
||||||
|
async searchGenre(
|
||||||
|
@Request() req: any,
|
||||||
|
@Query('include') include: string,
|
||||||
|
@Param('query') query: string,
|
||||||
|
): Promise<Genre[] | null> {
|
||||||
try {
|
try {
|
||||||
const ret = await this.searchService.genreByGuess(query, req.user?.id);
|
const ret = await this.searchService.genreByGuess(
|
||||||
|
query,
|
||||||
|
req.user?.id,
|
||||||
|
mapInclude(include, req, GenreController.includableFields),
|
||||||
|
);
|
||||||
if (!ret.length) throw new NotFoundException();
|
if (!ret.length) throw new NotFoundException();
|
||||||
else return ret;
|
else return ret;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -49,13 +78,24 @@ export class SearchController {
|
|||||||
|
|
||||||
@Get('artists/:query')
|
@Get('artists/:query')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
async searchArtists(@Request() req: any, @Param('query') query: string): Promise<Artist[] | null> {
|
@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,
|
||||||
|
): Promise<Artist[] | null> {
|
||||||
try {
|
try {
|
||||||
const ret = await this.searchService.artistByGuess(query, req.user?.id);
|
const ret = await this.searchService.artistByGuess(
|
||||||
|
query,
|
||||||
|
req.user?.id,
|
||||||
|
mapInclude(include, req, ArtistController.includableFields),
|
||||||
|
);
|
||||||
if (!ret.length) throw new NotFoundException();
|
if (!ret.length) throw new NotFoundException();
|
||||||
else return ret;
|
else return ret;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new InternalServerErrorException();
|
throw new InternalServerErrorException();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,51 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Album, Artist, Prisma, Song, Genre } from '@prisma/client';
|
import { Artist, Prisma, Song, Genre } from '@prisma/client';
|
||||||
import { HistoryService } from 'src/history/history.service';
|
import { HistoryService } from 'src/history/history.service';
|
||||||
import { PrismaService } from 'src/prisma/prisma.service';
|
import { PrismaService } from 'src/prisma/prisma.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SearchService {
|
export class SearchService {
|
||||||
constructor(private prisma: PrismaService, private history: HistoryService) { }
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
private history: HistoryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async songByGuess(query: string, userID: number): Promise<Song[]> {
|
async songByGuess(
|
||||||
|
query: string,
|
||||||
|
userID: number,
|
||||||
|
include?: Prisma.SongInclude,
|
||||||
|
): Promise<Song[]> {
|
||||||
return this.prisma.song.findMany({
|
return this.prisma.song.findMany({
|
||||||
where: {
|
where: {
|
||||||
name: { contains: query, mode: 'insensitive' },
|
name: { contains: query, mode: 'insensitive' },
|
||||||
},
|
},
|
||||||
|
include,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async genreByGuess(query: string, userID: number): Promise<Genre[]> {
|
async genreByGuess(
|
||||||
|
query: string,
|
||||||
|
userID: number,
|
||||||
|
include?: Prisma.GenreInclude,
|
||||||
|
): Promise<Genre[]> {
|
||||||
return this.prisma.genre.findMany({
|
return this.prisma.genre.findMany({
|
||||||
where: {
|
where: {
|
||||||
name: { contains: query, mode: 'insensitive' },
|
name: { contains: query, mode: 'insensitive' },
|
||||||
},
|
},
|
||||||
|
include,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async artistByGuess(query: string, userID: number): Promise<Artist[]> {
|
async artistByGuess(
|
||||||
|
query: string,
|
||||||
|
userID: number,
|
||||||
|
include?: Prisma.ArtistInclude,
|
||||||
|
): Promise<Artist[]> {
|
||||||
return this.prisma.artist.findMany({
|
return this.prisma.artist.findMany({
|
||||||
where: {
|
where: {
|
||||||
name: { contains: query, mode: 'insensitive' },
|
name: { contains: query, mode: 'insensitive' },
|
||||||
},
|
},
|
||||||
|
include,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { SettingsService } from './settings.service';
|
|||||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule],
|
||||||
providers: [SettingsService],
|
providers: [SettingsService],
|
||||||
exports: [SettingsService],
|
exports: [SettingsService],
|
||||||
})
|
})
|
||||||
export class SettingsModule {}
|
export class SettingsModule {}
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ export class SettingsService {
|
|||||||
user: {
|
user: {
|
||||||
connect: {
|
connect: {
|
||||||
id: userId,
|
id: userId,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateUserSettings(params: {
|
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({
|
return this.prisma.userSettings.delete({
|
||||||
where,
|
where,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,19 +16,38 @@ import {
|
|||||||
StreamableFile,
|
StreamableFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Plage } from 'src/models/plage';
|
import { ApiOkResponsePlaginated, Plage } from 'src/models/plage';
|
||||||
import { CreateSongDto } from './dto/create-song.dto';
|
import { CreateSongDto } from './dto/create-song.dto';
|
||||||
import { SongService } from './song.service';
|
import { SongService } from './song.service';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { Prisma, Song } from '@prisma/client';
|
import { Prisma, Song } from '@prisma/client';
|
||||||
import { createReadStream, existsSync } from 'fs';
|
import { createReadStream, existsSync } from 'fs';
|
||||||
import { ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiNotFoundResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiProperty,
|
||||||
|
ApiTags,
|
||||||
|
ApiUnauthorizedResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import { HistoryService } from 'src/history/history.service';
|
import { HistoryService } from 'src/history/history.service';
|
||||||
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
|
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
|
||||||
import { FilterQuery } from 'src/utils/filter.pipe';
|
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';
|
||||||
|
|
||||||
|
class SongHistoryResult {
|
||||||
|
@ApiProperty()
|
||||||
|
best: number;
|
||||||
|
@ApiProperty({ type: SongHistory, isArray: true })
|
||||||
|
history: SongHistory[];
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('song')
|
@Controller('song')
|
||||||
@ApiTags('song')
|
@ApiTags('song')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
export class SongController {
|
export class SongController {
|
||||||
static filterableFields: string[] = [
|
static filterableFields: string[] = [
|
||||||
'+id',
|
'+id',
|
||||||
@@ -37,6 +56,13 @@ export class SongController {
|
|||||||
'+albumId',
|
'+albumId',
|
||||||
'+genreId',
|
'+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(
|
constructor(
|
||||||
private readonly songService: SongService,
|
private readonly songService: SongService,
|
||||||
@@ -44,6 +70,9 @@ export class SongController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get(':id/midi')
|
@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) {
|
async getMidi(@Param('id', ParseIntPipe) id: number) {
|
||||||
const song = await this.songService.song({ id });
|
const song = await this.songService.song({ id });
|
||||||
if (!song) throw new NotFoundException('Song not found');
|
if (!song) throw new NotFoundException('Song not found');
|
||||||
@@ -57,6 +86,12 @@ export class SongController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/illustration')
|
@Get(':id/illustration')
|
||||||
|
@ApiOperation({
|
||||||
|
description: 'Streams the illustration of the requested song',
|
||||||
|
})
|
||||||
|
@ApiNotFoundResponse({ description: 'Song not found' })
|
||||||
|
@ApiOkResponse({ description: 'Returns the illustration succesfully' })
|
||||||
|
@Public()
|
||||||
async getIllustration(@Param('id', ParseIntPipe) id: number) {
|
async getIllustration(@Param('id', ParseIntPipe) id: number) {
|
||||||
const song = await this.songService.song({ id });
|
const song = await this.songService.song({ id });
|
||||||
if (!song) throw new NotFoundException('Song not found');
|
if (!song) throw new NotFoundException('Song not found');
|
||||||
@@ -74,6 +109,11 @@ export class SongController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/musicXml')
|
@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) {
|
async getMusicXml(@Param('id', ParseIntPipe) id: number) {
|
||||||
const song = await this.songService.song({ id });
|
const song = await this.songService.song({ id });
|
||||||
if (!song) throw new NotFoundException('Song not found');
|
if (!song) throw new NotFoundException('Song not found');
|
||||||
@@ -83,6 +123,10 @@ export class SongController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@ApiOperation({
|
||||||
|
description:
|
||||||
|
'register a new song in the database, should not be used by the frontend',
|
||||||
|
})
|
||||||
async create(@Body() createSongDto: CreateSongDto) {
|
async create(@Body() createSongDto: CreateSongDto) {
|
||||||
try {
|
try {
|
||||||
return await this.songService.createSong({
|
return await this.songService.createSong({
|
||||||
@@ -105,6 +149,7 @@ export class SongController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@ApiOperation({ description: 'delete a song by id' })
|
||||||
async remove(@Param('id', ParseIntPipe) id: number) {
|
async remove(@Param('id', ParseIntPipe) id: number) {
|
||||||
try {
|
try {
|
||||||
return await this.songService.deleteSong({ id });
|
return await this.songService.deleteSong({ id });
|
||||||
@@ -114,9 +159,11 @@ export class SongController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@ApiOkResponsePlaginated(_Song)
|
||||||
async findAll(
|
async findAll(
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@FilterQuery(SongController.filterableFields) where: Prisma.SongWhereInput,
|
@FilterQuery(SongController.filterableFields) where: Prisma.SongWhereInput,
|
||||||
|
@Query('include') include: string,
|
||||||
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||||
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
|
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||||
): Promise<Plage<Song>> {
|
): Promise<Plage<Song>> {
|
||||||
@@ -124,13 +171,26 @@ export class SongController {
|
|||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
where,
|
where,
|
||||||
|
include: mapInclude(include, req, SongController.includableFields),
|
||||||
});
|
});
|
||||||
return new Plage(ret, req);
|
return new Plage(ret, req);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async findOne(@Param('id', ParseIntPipe) id: number) {
|
@ApiOperation({ description: 'Get a specific song data' })
|
||||||
const res = await this.songService.song({ id });
|
@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;
|
return res;
|
||||||
@@ -138,7 +198,13 @@ export class SongController {
|
|||||||
|
|
||||||
@Get(':id/history')
|
@Get(':id/history')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@UseGuards(JwtAuthGuard)
|
@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' })
|
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||||
async getHistory(@Req() req: any, @Param('id', ParseIntPipe) id: number) {
|
async getHistory(@Req() req: any, @Param('id', ParseIntPipe) id: number) {
|
||||||
return this.historyService.getForSong({
|
return this.historyService.getForSong({
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ import { PrismaService } from 'src/prisma/prisma.service';
|
|||||||
export class SongService {
|
export class SongService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async songByArtist(data: number): Promise<Song[]> {
|
||||||
|
return this.prisma.song.findMany({
|
||||||
|
where: {
|
||||||
|
artistId: { equals: data },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async createSong(data: Prisma.SongCreateInput): Promise<Song> {
|
async createSong(data: Prisma.SongCreateInput): Promise<Song> {
|
||||||
return this.prisma.song.create({
|
return this.prisma.song.create({
|
||||||
data,
|
data,
|
||||||
@@ -14,9 +22,11 @@ export class SongService {
|
|||||||
|
|
||||||
async song(
|
async song(
|
||||||
songWhereUniqueInput: Prisma.SongWhereUniqueInput,
|
songWhereUniqueInput: Prisma.SongWhereUniqueInput,
|
||||||
|
include?: Prisma.SongInclude,
|
||||||
): Promise<Song | null> {
|
): Promise<Song | null> {
|
||||||
return this.prisma.song.findUnique({
|
return this.prisma.song.findUnique({
|
||||||
where: songWhereUniqueInput,
|
where: songWhereUniqueInput,
|
||||||
|
include,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,14 +36,16 @@ export class SongService {
|
|||||||
cursor?: Prisma.SongWhereUniqueInput;
|
cursor?: Prisma.SongWhereUniqueInput;
|
||||||
where?: Prisma.SongWhereInput;
|
where?: Prisma.SongWhereInput;
|
||||||
orderBy?: Prisma.SongOrderByWithRelationInput;
|
orderBy?: Prisma.SongOrderByWithRelationInput;
|
||||||
|
include?: Prisma.SongInclude;
|
||||||
}): Promise<Song[]> {
|
}): Promise<Song[]> {
|
||||||
const { skip, take, cursor, where, orderBy } = params;
|
const { skip, take, cursor, where, orderBy, include } = params;
|
||||||
return this.prisma.song.findMany({
|
return this.prisma.song.findMany({
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
cursor,
|
cursor,
|
||||||
where,
|
where,
|
||||||
orderBy,
|
orderBy,
|
||||||
|
include,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { Controller, Get, Param, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Param,
|
||||||
|
NotFoundException,
|
||||||
|
Response,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service';
|
||||||
import { ApiNotFoundResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiNotFoundResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { User } from 'src/models/user';
|
import { User } from 'src/models/user';
|
||||||
|
|
||||||
@ApiTags('users')
|
@ApiTags('users')
|
||||||
@@ -20,4 +27,12 @@ export class UsersController {
|
|||||||
if (!ret) throw new NotFoundException();
|
if (!ret) throw new NotFoundException();
|
||||||
return ret;
|
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,14 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
InternalServerErrorException,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { User, Prisma } from '@prisma/client';
|
import { User, Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from 'src/prisma/prisma.service';
|
import { PrismaService } from 'src/prisma/prisma.service';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
import { randomUUID } from 'crypto';
|
import { createHash, randomUUID } from 'crypto';
|
||||||
|
import { createReadStream, existsSync } from 'fs';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
@@ -34,7 +40,7 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createUser(data: Prisma.UserCreateInput): Promise<User> {
|
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({
|
return this.prisma.user.create({
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
@@ -46,7 +52,7 @@ export class UsersService {
|
|||||||
username: `Guest ${randomUUID()}`,
|
username: `Guest ${randomUUID()}`,
|
||||||
isGuest: true,
|
isGuest: true,
|
||||||
// Not realyl clean but better than a separate table or breaking the api by adding nulls.
|
// Not realyl clean but better than a separate table or breaking the api by adding nulls.
|
||||||
email: '',
|
email: null,
|
||||||
password: '',
|
password: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -72,4 +78,42 @@ export class UsersService {
|
|||||||
where,
|
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) {
|
||||||
|
return this.prisma.likedSongs.findMany({
|
||||||
|
where: { userId: userId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeLikedSong(userId: number, songId: number) {
|
||||||
|
return this.prisma.likedSongs.deleteMany({
|
||||||
|
where: { userId: userId, songId: songId },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ Register Duplicates
|
|||||||
# We can't use the `Register` keyword because it assert for success
|
# We can't use the `Register` keyword because it assert for success
|
||||||
POST /auth/register {"username": "user-duplicate", "password": "pass", "email": "mail@kyoo.moe"}
|
POST /auth/register {"username": "user-duplicate", "password": "pass", "email": "mail@kyoo.moe"}
|
||||||
Output
|
Output
|
||||||
Integer response status 400
|
Integer response status 409
|
||||||
Login user-duplicate
|
Login user-duplicate
|
||||||
[Teardown] DELETE /auth/me
|
[Teardown] DELETE /auth/me
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ Documentation Tests of the /song route.
|
|||||||
... Ensures that the songs CRUD works corectly.
|
... Ensures that the songs CRUD works corectly.
|
||||||
|
|
||||||
Resource ../rest.resource
|
Resource ../rest.resource
|
||||||
|
Resource ../auth/auth.resource
|
||||||
|
|
||||||
|
|
||||||
*** Test Cases ***
|
*** Test Cases ***
|
||||||
@@ -133,5 +134,47 @@ Get midi file
|
|||||||
Integer response status 201
|
Integer response status 201
|
||||||
GET /song/${res.body.id}/midi
|
GET /song/${res.body.id}/midi
|
||||||
Integer response status 200
|
Integer response status 200
|
||||||
#Output
|
# Output
|
||||||
[Teardown] DELETE /song/${res.body.id}
|
[Teardown] DELETE /song/${res.body.id}
|
||||||
|
|
||||||
|
Find a song with artist
|
||||||
|
[Documentation] Create a song and find it with it's artist
|
||||||
|
&{res2}= POST /artist { "name": "Tghjmk"}
|
||||||
|
Output
|
||||||
|
Integer response status 201
|
||||||
|
&{res}= POST
|
||||||
|
... /song
|
||||||
|
... {"name": "Mama miaeyi", "artistId": ${res2.body.id}, "difficulties": {}, "midiPath": "/musics/Beethoven-125-4.midi", "musicXmlPath": "/musics/Beethoven-125-4.mxl"}
|
||||||
|
Output
|
||||||
|
Integer response status 201
|
||||||
|
&{get}= GET /song/${res.body.id}?include=artist
|
||||||
|
Output
|
||||||
|
Integer response status 200
|
||||||
|
Should Be Equal ${res2.body} ${get.body.artist}
|
||||||
|
[Teardown] Run Keywords DELETE /song/${res.body.id}
|
||||||
|
... AND DELETE /artist/${res2.body.id}
|
||||||
|
|
||||||
|
Find a song with artist and history
|
||||||
|
[Documentation] Create a song and find it with it's artist
|
||||||
|
${userID}= RegisterLogin wowusersfkj
|
||||||
|
&{res2}= POST /artist { "name": "Tghjmk"}
|
||||||
|
Output
|
||||||
|
Integer response status 201
|
||||||
|
&{res}= POST
|
||||||
|
... /song
|
||||||
|
... {"name": "Mama miaeyi", "artistId": ${res2.body.id}, "difficulties": {}, "midiPath": "/musics/Beethoven-125-4.midi", "musicXmlPath": "/musics/Beethoven-125-4.mxl"}
|
||||||
|
Output
|
||||||
|
Integer response status 201
|
||||||
|
&{res3}= POST
|
||||||
|
... /history
|
||||||
|
... { "songID": ${res.body.id}, "userID": ${userID}, "score": 12, "difficulties": {}, "info": {} }
|
||||||
|
Output
|
||||||
|
Integer response status 201
|
||||||
|
&{get}= GET /song/${res.body.id}?include=artist,SongHistory
|
||||||
|
Output
|
||||||
|
Integer response status 200
|
||||||
|
Should Be Equal ${res2.body} ${get.body.artist}
|
||||||
|
Should Be Equal ${res3.body} ${get.body.SongHistory[0]}
|
||||||
|
[Teardown] Run Keywords DELETE /auth/me
|
||||||
|
... AND DELETE /song/${res.body.id}
|
||||||
|
... AND DELETE /artist/${res2.body.id}
|
||||||
|
|||||||
37
config/logs_nginx.conf
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
user nginx;
|
||||||
|
worker_processes 5; ## Default: 1
|
||||||
|
events {
|
||||||
|
worker_connections 1000;
|
||||||
|
}
|
||||||
|
http {
|
||||||
|
resolver 127.0.0.11;
|
||||||
|
server {
|
||||||
|
listen 3100;
|
||||||
|
location = / {
|
||||||
|
return 200 'OK';
|
||||||
|
auth_basic off;
|
||||||
|
}
|
||||||
|
location = /api/prom/push {
|
||||||
|
proxy_pass http://write:3100\$$request_uri;
|
||||||
|
}
|
||||||
|
location = /api/prom/tail {
|
||||||
|
proxy_pass http://read:3100\$$request_uri;
|
||||||
|
proxy_set_header Upgrade \$$http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
location ~ /api/prom/.* {
|
||||||
|
proxy_pass http://read:3100\$$request_uri;
|
||||||
|
}
|
||||||
|
location = /loki/api/v1/push {
|
||||||
|
proxy_pass http://write:3100\$$request_uri;
|
||||||
|
}
|
||||||
|
location = /loki/api/v1/tail {
|
||||||
|
proxy_pass http://read:3100\$$request_uri;
|
||||||
|
proxy_set_header Upgrade \$$http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
location ~ /loki/api/.* {
|
||||||
|
proxy_pass http://read:3100\$$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
config/loki-config.yaml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
auth_enabled: false
|
||||||
|
server:
|
||||||
|
http_listen_port: 3100
|
||||||
|
memberlist:
|
||||||
|
join_members:
|
||||||
|
- loki:7946
|
||||||
|
schema_config:
|
||||||
|
configs:
|
||||||
|
- from: 2021-08-01
|
||||||
|
store: boltdb-shipper
|
||||||
|
object_store: s3
|
||||||
|
schema: v11
|
||||||
|
index:
|
||||||
|
prefix: index_
|
||||||
|
period: 24h
|
||||||
|
common:
|
||||||
|
path_prefix: /loki
|
||||||
|
replication_factor: 1
|
||||||
|
storage:
|
||||||
|
s3:
|
||||||
|
endpoint: minio:9000
|
||||||
|
insecure: true
|
||||||
|
bucketnames: loki-data
|
||||||
|
access_key_id: loki
|
||||||
|
secret_access_key: 12345678
|
||||||
|
s3forcepathstyle: true
|
||||||
|
ring:
|
||||||
|
kvstore:
|
||||||
|
store: memberlist
|
||||||
|
query_range:
|
||||||
|
parallelise_shardable_queries: false
|
||||||
|
ruler:
|
||||||
|
storage:
|
||||||
|
s3:
|
||||||
|
bucketnames: loki-ruler
|
||||||
22
config/promtail-local-config.yaml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
server:
|
||||||
|
http_listen_port: 9080
|
||||||
|
grpc_listen_port: 0
|
||||||
|
|
||||||
|
positions:
|
||||||
|
filename: /tmp/positions.yaml
|
||||||
|
|
||||||
|
clients:
|
||||||
|
- url: http://gateway:3100/loki/api/v1/push
|
||||||
|
tenant_id: tenant1
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: flog_scrape
|
||||||
|
docker_sd_configs:
|
||||||
|
- host: unix:///var/run/docker.sock
|
||||||
|
refresh_interval: 5s
|
||||||
|
relabel_configs:
|
||||||
|
- source_labels: ['__meta_docker_container_name']
|
||||||
|
regex: '/(.*)'
|
||||||
|
target_label: 'container'
|
||||||
|
|
||||||
1
data/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*
|
||||||
@@ -1,3 +1,10 @@
|
|||||||
|
networks:
|
||||||
|
loki:
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
scoro_logs:
|
||||||
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
back:
|
back:
|
||||||
build:
|
build:
|
||||||
@@ -9,6 +16,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./back:/app
|
- ./back:/app
|
||||||
- ./assets:/assets
|
- ./assets:/assets
|
||||||
|
- ./data:/data
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -24,6 +32,9 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./scorometer:/app
|
- ./scorometer:/app
|
||||||
- ./assets:/assets
|
- ./assets:/assets
|
||||||
|
- scoro_logs:/logs
|
||||||
|
networks:
|
||||||
|
- loki
|
||||||
|
|
||||||
db:
|
db:
|
||||||
container_name: db
|
container_name: db
|
||||||
@@ -39,13 +50,14 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
|
|
||||||
front:
|
front:
|
||||||
build:
|
build:
|
||||||
context: ./front
|
context: ./front
|
||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
environment:
|
environment:
|
||||||
- SCOROMETER_URL=http://scorometer:6543/
|
- SCOROMETER_URL=http://scorometer:6543/
|
||||||
- NGINX_PORT=80
|
- NGINX_PORT=4567
|
||||||
ports:
|
ports:
|
||||||
- "19006:19006"
|
- "19006:19006"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -54,3 +66,20 @@ services:
|
|||||||
- "back"
|
- "back"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx
|
||||||
|
environment:
|
||||||
|
- API_URL=${API_URL:-http://back:3000}
|
||||||
|
- SCOROMETER_URL=${SCOROMETER_URL:-http://scorometer:6543}
|
||||||
|
- FRONT_URL=${FRONT_URL:-http://front:19006}
|
||||||
|
- PORT=4567
|
||||||
|
depends_on:
|
||||||
|
- back
|
||||||
|
- front
|
||||||
|
- scorometer
|
||||||
|
volumes:
|
||||||
|
- "./front/assets:/assets:ro"
|
||||||
|
- "./front/nginx.conf.template.dev:/etc/nginx/templates/default.conf.template:ro"
|
||||||
|
ports:
|
||||||
|
- "4567:4567"
|
||||||
|
|||||||
185
docker-compose.log.yml
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
services:
|
||||||
|
read:
|
||||||
|
image: grafana/loki:2.8.2
|
||||||
|
command: "-config.file=/etc/loki/config.yaml -target=read"
|
||||||
|
ports:
|
||||||
|
- 3101:3100
|
||||||
|
- 7946
|
||||||
|
- 9095
|
||||||
|
volumes:
|
||||||
|
- ./config/loki-config.yaml:/etc/loki/config.yaml
|
||||||
|
depends_on:
|
||||||
|
- minio
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3100/ready || exit 1" ]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks: &loki-dns
|
||||||
|
loki:
|
||||||
|
aliases:
|
||||||
|
- loki
|
||||||
|
|
||||||
|
write:
|
||||||
|
image: grafana/loki:2.8.2
|
||||||
|
command: "-config.file=/etc/loki/config.yaml -target=write"
|
||||||
|
ports:
|
||||||
|
- 3102:3100
|
||||||
|
- 7946
|
||||||
|
- 9095
|
||||||
|
volumes:
|
||||||
|
- ./config/loki-config.yaml:/etc/loki/config.yaml
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3100/ready || exit 1" ]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
depends_on:
|
||||||
|
- minio
|
||||||
|
networks:
|
||||||
|
<<: *loki-dns
|
||||||
|
|
||||||
|
promtail:
|
||||||
|
image: grafana/promtail:2.8.2
|
||||||
|
volumes:
|
||||||
|
- ./config/promtail-local-config.yaml:/etc/promtail/config.yaml:ro
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
command: -config.file=/etc/promtail/config.yaml
|
||||||
|
depends_on:
|
||||||
|
- gateway
|
||||||
|
networks:
|
||||||
|
- loki
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:RELEASE.2023-07-21T21-12-44Z
|
||||||
|
entrypoint:
|
||||||
|
- sh
|
||||||
|
- -euc
|
||||||
|
- |
|
||||||
|
mkdir -p /data/loki-data && \
|
||||||
|
mkdir -p /data/loki-ruler && \
|
||||||
|
minio server /data
|
||||||
|
environment:
|
||||||
|
- MINIO_ROOT_USER=loki
|
||||||
|
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}
|
||||||
|
- MINIO_PROMETHEUS_AUTH_TYPE=public
|
||||||
|
- MINIO_UPDATE=off
|
||||||
|
ports:
|
||||||
|
- 9000
|
||||||
|
volumes:
|
||||||
|
- ./.data/minio:/data
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD", "curl", "-f", "http://localhost:9000/minio/health/live" ]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 20s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- loki
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:9.5.6
|
||||||
|
environment:
|
||||||
|
- GF_PATHS_PROVISIONING=/etc/grafana/provisioning
|
||||||
|
- GF_AUTH_ANONYMOUS_ENABLED=true
|
||||||
|
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
|
||||||
|
depends_on:
|
||||||
|
- gateway
|
||||||
|
entrypoint:
|
||||||
|
- sh
|
||||||
|
- -euc
|
||||||
|
- |
|
||||||
|
mkdir -p /etc/grafana/provisioning/datasources
|
||||||
|
cat <<EOF > /etc/grafana/provisioning/datasources/ds.yaml
|
||||||
|
apiVersion: 1
|
||||||
|
datasources:
|
||||||
|
- name: Loki
|
||||||
|
type: loki
|
||||||
|
access: proxy
|
||||||
|
url: http://gateway:3100
|
||||||
|
jsonData:
|
||||||
|
httpHeaderName1: "X-Scope-OrgID"
|
||||||
|
secureJsonData:
|
||||||
|
httpHeaderValue1: "tenant1"
|
||||||
|
EOF
|
||||||
|
/run.sh
|
||||||
|
ports:
|
||||||
|
- "3001:3000"
|
||||||
|
volumes:
|
||||||
|
- ./grafana/dashboard.yaml:/etc/grafana/provisioning/dashboards/main.yaml
|
||||||
|
- ./grafana/dashboards:/var/lib/grafana/dashboards
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3001/api/health || exit 1" ]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- loki
|
||||||
|
|
||||||
|
gateway:
|
||||||
|
image: nginx:1.25.1
|
||||||
|
depends_on:
|
||||||
|
- read
|
||||||
|
- write
|
||||||
|
entrypoint:
|
||||||
|
- sh
|
||||||
|
- -euc
|
||||||
|
- |
|
||||||
|
cat <<EOF > /etc/nginx/nginx.conf
|
||||||
|
user nginx;
|
||||||
|
worker_processes 5; ## Default: 1
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
resolver 127.0.0.11;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 3100;
|
||||||
|
|
||||||
|
location = / {
|
||||||
|
return 200 'OK';
|
||||||
|
auth_basic off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /api/prom/push {
|
||||||
|
proxy_pass http://write:3100\$$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /api/prom/tail {
|
||||||
|
proxy_pass http://read:3100\$$request_uri;
|
||||||
|
proxy_set_header Upgrade \$$http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ /api/prom/.* {
|
||||||
|
proxy_pass http://read:3100\$$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /loki/api/v1/push {
|
||||||
|
proxy_pass http://write:3100\$$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /loki/api/v1/tail {
|
||||||
|
proxy_pass http://read:3100\$$request_uri;
|
||||||
|
proxy_set_header Upgrade \$$http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ /loki/api/.* {
|
||||||
|
proxy_pass http://read:3100\$$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
/docker-entrypoint.sh nginx -g "daemon off;"
|
||||||
|
ports:
|
||||||
|
- "3100:3100"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "service", "nginx", "status"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- loki
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
networks:
|
||||||
|
loki:
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
scoro_logs:
|
||||||
|
|
||||||
services:
|
services:
|
||||||
back:
|
back:
|
||||||
image: ghcr.io/chroma-case/back:main
|
image: ghcr.io/chroma-case/back:main
|
||||||
@@ -10,18 +16,20 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./assets:/assets
|
- ./assets:/assets
|
||||||
|
- ./data:/data
|
||||||
scorometer:
|
scorometer:
|
||||||
image: ghcr.io/chroma-case/scorometer:main
|
image: ghcr.io/chroma-case/scorometer:main
|
||||||
ports:
|
ports:
|
||||||
- "6543:6543"
|
- "6543:6543"
|
||||||
volumes:
|
volumes:
|
||||||
|
- scoro_logs:/logs
|
||||||
- ./assets:/assets
|
- ./assets:/assets
|
||||||
db:
|
db:
|
||||||
container_name: db
|
container_name: db
|
||||||
image: postgres:alpine3.14
|
image: postgres:alpine3.14
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=${POSTGRES_USER}
|
- POSTGRES_USER=${POSTGRES_USER}
|
||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORDgrafana}
|
||||||
- POSTGRES_DB=${POSTGRES_DB}
|
- POSTGRES_DB=${POSTGRES_DB}
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
@@ -42,4 +50,4 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- "back"
|
- "back"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
@@ -1,3 +1,12 @@
|
|||||||
|
networks:
|
||||||
|
loki:
|
||||||
|
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db:
|
||||||
|
scoro_logs:
|
||||||
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
back:
|
back:
|
||||||
build: ./back
|
build: ./back
|
||||||
@@ -10,12 +19,14 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./assets:/assets
|
- ./assets:/assets
|
||||||
|
- ./data:/data
|
||||||
scorometer:
|
scorometer:
|
||||||
build: ./scorometer
|
build: ./scorometer
|
||||||
ports:
|
ports:
|
||||||
- "6543:6543"
|
- "6543:6543"
|
||||||
volumes:
|
volumes:
|
||||||
- ./assets:/assets
|
- ./assets:/assets
|
||||||
|
- scoro_logs:/logs
|
||||||
db:
|
db:
|
||||||
container_name: db
|
container_name: db
|
||||||
image: postgres:alpine3.14
|
image: postgres:alpine3.14
|
||||||
@@ -34,11 +45,7 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
front:
|
front:
|
||||||
build:
|
build: ./front
|
||||||
context: ./front
|
|
||||||
args:
|
|
||||||
- API_URL=${API_URL}
|
|
||||||
- SCORO_URL=${SCORO_URL}
|
|
||||||
environment:
|
environment:
|
||||||
- API_URL=http://back:3000/
|
- API_URL=http://back:3000/
|
||||||
- SCOROMETER_URL=http://scorometer:6543/
|
- SCOROMETER_URL=http://scorometer:6543/
|
||||||
@@ -48,7 +55,4 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- "back"
|
- "back"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
|
||||||
volumes:
|
|
||||||
db:
|
|
||||||
43
flake.lock
generated
@@ -1,43 +0,0 @@
|
|||||||
{
|
|
||||||
"nodes": {
|
|
||||||
"flake-utils": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1659877975,
|
|
||||||
"narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=",
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1665573177,
|
|
||||||
"narHash": "sha256-Arkrf3zmi3lXYpbSe9H+HQxswQ6jxsAmeQVq5Sr/OZc=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "d2afb051ffd904af5a825f58abee3c63b148c5f2",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "master",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
|
||||||
"inputs": {
|
|
||||||
"flake-utils": "flake-utils",
|
|
||||||
"nixpkgs": "nixpkgs"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": "root",
|
|
||||||
"version": 7
|
|
||||||
}
|
|
||||||
31
flake.nix
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
description = "A prisma test project";
|
|
||||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/master";
|
|
||||||
inputs.flake-utils.url = "github:numtide/flake-utils";
|
|
||||||
|
|
||||||
outputs = { self, nixpkgs, flake-utils }:
|
|
||||||
flake-utils.lib.eachDefaultSystem (system: let
|
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
|
||||||
in {
|
|
||||||
devShell = pkgs.mkShell {
|
|
||||||
nativeBuildInputs = [ pkgs.bashInteractive ];
|
|
||||||
buildInputs = with pkgs; [
|
|
||||||
nodePackages.prisma
|
|
||||||
nodePackages."@nestjs/cli"
|
|
||||||
nodePackages.npm
|
|
||||||
nodejs-slim
|
|
||||||
yarn
|
|
||||||
python3
|
|
||||||
pkg-config
|
|
||||||
];
|
|
||||||
shellHook = with pkgs; ''
|
|
||||||
export PRISMA_MIGRATION_ENGINE_BINARY="${prisma-engines}/bin/migration-engine"
|
|
||||||
export PRISMA_QUERY_ENGINE_BINARY="${prisma-engines}/bin/query-engine"
|
|
||||||
export PRISMA_QUERY_ENGINE_LIBRARY="${prisma-engines}/lib/libquery_engine.node"
|
|
||||||
export PRISMA_INTROSPECTION_ENGINE_BINARY="${prisma-engines}/bin/introspection-engine"
|
|
||||||
export PRISMA_FMT_BINARY="${prisma-engines}/bin/prisma-fmt"
|
|
||||||
export DATABASE_URL=postgresql://user:eip@localhost:5432/chromacase
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.expo/
|
.expo/
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.dockerignore
|
||||||
|
Dockerfile
|
||||||
|
Dockerfile.dev
|
||||||
|
|||||||
31
front/.gitignore
vendored
@@ -1,18 +1,35 @@
|
|||||||
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
|
|
||||||
|
# dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
# Expo
|
||||||
.expo/
|
.expo/
|
||||||
dist/
|
dist/
|
||||||
npm-debug.*
|
web-build/
|
||||||
|
|
||||||
|
# Native
|
||||||
|
*.orig.*
|
||||||
*.jks
|
*.jks
|
||||||
*.p8
|
*.p8
|
||||||
*.p12
|
*.p12
|
||||||
*.key
|
*.key
|
||||||
*.mobileprovision
|
*.mobileprovision
|
||||||
*.orig.*
|
|
||||||
web-build/
|
# Metro
|
||||||
*.apk
|
.metro-health-check*
|
||||||
yarn.error*
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
.idea/
|
# local env files
|
||||||
.expo
|
.env*.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|||||||
126
front/API.ts
@@ -4,10 +4,10 @@ import Chapter from './models/Chapter';
|
|||||||
import Lesson from './models/Lesson';
|
import Lesson from './models/Lesson';
|
||||||
import Genre, { GenreHandler } from './models/Genre';
|
import Genre, { GenreHandler } from './models/Genre';
|
||||||
import LessonHistory from './models/LessonHistory';
|
import LessonHistory from './models/LessonHistory';
|
||||||
|
import likedSong, { LikedSongHandler } from './models/LikedSong';
|
||||||
import Song, { SongHandler } from './models/Song';
|
import Song, { SongHandler } from './models/Song';
|
||||||
import { SongHistoryHandler, SongHistoryItem, SongHistoryItemHandler } from './models/SongHistory';
|
import { SongHistoryHandler, SongHistoryItem, SongHistoryItemHandler } from './models/SongHistory';
|
||||||
import User, { UserHandler } from './models/User';
|
import User, { UserHandler } from './models/User';
|
||||||
import Constants from 'expo-constants';
|
|
||||||
import store from './state/Store';
|
import store from './state/Store';
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
import { en } from './i18n/Translations';
|
import { en } from './i18n/Translations';
|
||||||
@@ -21,6 +21,8 @@ import { PlageHandler } from './models/Plage';
|
|||||||
import { ListHandler } from './models/List';
|
import { ListHandler } from './models/List';
|
||||||
import { AccessTokenResponseHandler } from './models/AccessTokenResponse';
|
import { AccessTokenResponseHandler } from './models/AccessTokenResponse';
|
||||||
import * as yup from 'yup';
|
import * as yup from 'yup';
|
||||||
|
import { base64ToBlob } from './utils/base64ToBlob';
|
||||||
|
import { ImagePickerAsset } from 'expo-image-picker';
|
||||||
|
|
||||||
type AuthenticationInput = { username: string; password: string };
|
type AuthenticationInput = { username: string; password: string };
|
||||||
type RegistrationInput = AuthenticationInput & { email: string };
|
type RegistrationInput = AuthenticationInput & { email: string };
|
||||||
@@ -30,6 +32,7 @@ export type AccessToken = string;
|
|||||||
type FetchParams = {
|
type FetchParams = {
|
||||||
route: string;
|
route: string;
|
||||||
body?: object;
|
body?: object;
|
||||||
|
formData?: FormData;
|
||||||
method?: 'GET' | 'POST' | 'DELETE' | 'PATCH' | 'PUT';
|
method?: 'GET' | 'POST' | 'DELETE' | 'PATCH' | 'PUT';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,9 +66,7 @@ export class ValidationError extends Error {
|
|||||||
|
|
||||||
export default class API {
|
export default class API {
|
||||||
public static readonly baseUrl =
|
public static readonly baseUrl =
|
||||||
process.env.NODE_ENV != 'development' && Platform.OS === 'web'
|
Platform.OS === 'web' ? '/api' : process.env.EXPO_PUBLIC_API_URL!;
|
||||||
? '/api'
|
|
||||||
: Constants.manifest?.extra?.apiUrl;
|
|
||||||
public static async fetch(
|
public static async fetch(
|
||||||
params: FetchParams,
|
params: FetchParams,
|
||||||
handle: Pick<Required<HandleParams>, 'raw'>
|
handle: Pick<Required<HandleParams>, 'raw'>
|
||||||
@@ -81,17 +82,30 @@ export default class API {
|
|||||||
public static async fetch(params: FetchParams): Promise<void>;
|
public static async fetch(params: FetchParams): Promise<void>;
|
||||||
public static async fetch(params: FetchParams, handle?: HandleParams) {
|
public static async fetch(params: FetchParams, handle?: HandleParams) {
|
||||||
const jwtToken = store.getState().user.accessToken;
|
const jwtToken = store.getState().user.accessToken;
|
||||||
const header = {
|
const headers = {
|
||||||
'Content-Type': 'application/json',
|
...(params.formData == undefined && { 'Content-Type': 'application/json' }),
|
||||||
|
...(jwtToken && { Authorization: `Bearer ${jwtToken}` }),
|
||||||
};
|
};
|
||||||
const response = await fetch(`${API.baseUrl}${params.route}`, {
|
const response = await fetch(`${API.baseUrl}${params.route}`, {
|
||||||
headers: (jwtToken && { ...header, Authorization: `Bearer ${jwtToken}` }) || header,
|
headers: headers,
|
||||||
body: JSON.stringify(params.body),
|
body: params.formData ?? JSON.stringify(params.body),
|
||||||
method: params.method ?? 'GET',
|
method: params.method ?? 'GET',
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
throw new Error('Error while fetching API: ' + API.baseUrl);
|
throw new Error('Error while fetching API: ' + API.baseUrl);
|
||||||
});
|
});
|
||||||
if (!handle || handle.emptyResponse) {
|
if (!handle || handle.emptyResponse) {
|
||||||
|
if (!response.ok) {
|
||||||
|
let responseMessage = response.statusText;
|
||||||
|
try {
|
||||||
|
const responseData = await response.json();
|
||||||
|
console.log(responseData);
|
||||||
|
if (responseData.message) responseMessage = responseData.message;
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
throw new APIError(response.statusText, response.status, 'unknownError');
|
||||||
|
}
|
||||||
|
throw new APIError(responseMessage, response.status, 'unknownError');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (handle.raw) {
|
if (handle.raw) {
|
||||||
@@ -102,7 +116,7 @@ export default class API {
|
|||||||
try {
|
try {
|
||||||
const jsonResponse = JSON.parse(body);
|
const jsonResponse = JSON.parse(body);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new APIError(response.statusText ?? body, response.status);
|
throw new APIError(response.statusText ?? body, response.status, 'unknownError');
|
||||||
}
|
}
|
||||||
const validated = await handler.validator.validate(jsonResponse).catch((e) => {
|
const validated = await handler.validator.validate(jsonResponse).catch((e) => {
|
||||||
if (e instanceof yup.ValidationError) {
|
if (e instanceof yup.ValidationError) {
|
||||||
@@ -164,6 +178,7 @@ export default class API {
|
|||||||
{
|
{
|
||||||
route: '/auth/guest',
|
route: '/auth/guest',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
body: undefined,
|
||||||
},
|
},
|
||||||
{ handler: AccessTokenResponseHandler }
|
{ handler: AccessTokenResponseHandler }
|
||||||
)
|
)
|
||||||
@@ -278,6 +293,43 @@ export default class API {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description retrieves songs from a specific artist
|
||||||
|
* @param artistId is the id of the artist that composed the songs aimed
|
||||||
|
* @returns a Promise of Songs type array
|
||||||
|
*/
|
||||||
|
public static getSongsByArtist(artistId: number): Query<Song[]> {
|
||||||
|
return {
|
||||||
|
key: ['artist', artistId, 'songs'],
|
||||||
|
exec: () =>
|
||||||
|
API.fetch(
|
||||||
|
{
|
||||||
|
route: `/song?artistId=${artistId}`,
|
||||||
|
},
|
||||||
|
{ handler: PlageHandler(SongHandler) }
|
||||||
|
).then(({ data }) => data),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all songs corresponding to the given genre ID
|
||||||
|
* @param genreId the id of the genre we're aiming
|
||||||
|
* @returns a promise of an array of Songs
|
||||||
|
*/
|
||||||
|
public static getSongsByGenre(genreId: number): Query<Song[]> {
|
||||||
|
return {
|
||||||
|
key: ['genre', genreId, 'songs'],
|
||||||
|
exec: () =>
|
||||||
|
API.fetch(
|
||||||
|
{
|
||||||
|
route: `/song?genreId=${genreId}`,
|
||||||
|
},
|
||||||
|
{ handler: PlageHandler(SongHandler) }
|
||||||
|
).then(({ data }) => data),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrive a song's midi partition
|
* Retrive a song's midi partition
|
||||||
* @param songId the id to find the song
|
* @param songId the id to find the song
|
||||||
@@ -313,6 +365,23 @@ export default class API {
|
|||||||
return `${API.baseUrl}/genre/${genreId}/illustration`;
|
return `${API.baseUrl}/genre/${genreId}/illustration`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a genre
|
||||||
|
* @param genreId the id of the aimed genre
|
||||||
|
*/
|
||||||
|
public static getGenre(genreId: number): Query<Genre> {
|
||||||
|
return {
|
||||||
|
key: ['genre', genreId],
|
||||||
|
exec: () =>
|
||||||
|
API.fetch(
|
||||||
|
{
|
||||||
|
route: `/genre/${genreId}`,
|
||||||
|
},
|
||||||
|
{ handler: GenreHandler }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrive a song's musicXML partition
|
* Retrive a song's musicXML partition
|
||||||
* @param songId the id to find the song
|
* @param songId the id to find the song
|
||||||
@@ -587,4 +656,43 @@ export default class API {
|
|||||||
{ handler: UserHandler }
|
{ handler: UserHandler }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async updateProfileAvatar(image: ImagePickerAsset): Promise<void> {
|
||||||
|
const data = await base64ToBlob(image.uri);
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
formData.append('file', data);
|
||||||
|
return API.fetch({
|
||||||
|
route: '/auth/me/picture',
|
||||||
|
method: 'POST',
|
||||||
|
formData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async addLikedSong(songId: number): Promise<void> {
|
||||||
|
await API.fetch({
|
||||||
|
route: `/auth/me/likes/${songId}`,
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async removeLikedSong(songId: number): Promise<void> {
|
||||||
|
await API.fetch({
|
||||||
|
route: `/auth/me/likes/${songId}`,
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getLikedSongs(): Query<likedSong[]> {
|
||||||
|
return {
|
||||||
|
key: ['liked songs'],
|
||||||
|
exec: () =>
|
||||||
|
API.fetch(
|
||||||
|
{
|
||||||
|
route: '/auth/me/likes',
|
||||||
|
},
|
||||||
|
{ handler: ListHandler(LikedSongHandler) }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import store, { persistor } from './state/Store';
|
import store, { persistor } from './state/Store';
|
||||||
@@ -10,12 +10,22 @@ import LanguageGate from './i18n/LanguageGate';
|
|||||||
import ThemeProvider, { ColorSchemeProvider } from './Theme';
|
import ThemeProvider, { ColorSchemeProvider } from './Theme';
|
||||||
import 'react-native-url-polyfill/auto';
|
import 'react-native-url-polyfill/auto';
|
||||||
import { QueryRules } from './Queries';
|
import { QueryRules } from './Queries';
|
||||||
|
import { useFonts } from 'expo-font';
|
||||||
|
|
||||||
const queryClient = new QueryClient(QueryRules);
|
const queryClient = new QueryClient(QueryRules);
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
setTimeout(SplashScreen.hideAsync, 500);
|
|
||||||
|
const [fontsLoaded] = useFonts({
|
||||||
|
Lexend: require('./assets/fonts/lexend.ttf'),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fontsLoaded) {
|
||||||
|
SplashScreen.hideAsync();
|
||||||
|
}
|
||||||
|
}, [fontsLoaded]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
|
|||||||
@@ -4,24 +4,24 @@
|
|||||||
FROM node:16-alpine as build
|
FROM node:16-alpine as build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# install expo cli
|
# install expo cli
|
||||||
RUN yarn global add expo-cli@6.0.5
|
RUN yarn global add expo-cli
|
||||||
# add sharp-cli (^2.1.0) for faster image processing
|
# add sharp-cli (^2.1.0) for faster image processing
|
||||||
RUN yarn global add sharp-cli@^2.1.0
|
RUN yarn global add sharp-cli
|
||||||
|
|
||||||
COPY package.json yarn.lock ./
|
COPY package.json yarn.lock ./
|
||||||
RUN yarn install
|
RUN yarn install --immutable
|
||||||
|
RUN expo install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
ARG API_URL
|
ARG API_URL
|
||||||
ENV API_URL=$API_URL
|
ENV EXPO_PUBLIC_API_URL=$API_URL
|
||||||
ARG SCORO_URL
|
ARG SCORO_URL
|
||||||
ENV SCORO_URL=$SCORO_URL
|
ENV EXPO_PUBLIC_API_URL=$SCORO_URL
|
||||||
|
|
||||||
RUN yarn tsc && expo build:web
|
RUN yarn tsc && npx expo export:web
|
||||||
|
|
||||||
# Serve the app
|
# Serve the app
|
||||||
FROM nginx:1.21-alpine
|
FROM nginx:1.21-alpine
|
||||||
COPY --from=build /app/web-build /usr/share/nginx/html
|
COPY --from=build /app/web-build /usr/share/nginx/html
|
||||||
COPY nginx.conf.template /etc/nginx/conf.d/default.conf.template
|
COPY ./assets/ /usr/share/nginx/html/assets/
|
||||||
|
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
|
||||||
CMD envsubst '$API_URL $SCOROMETER_URL $NGINX_PORT' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { RootState, useSelector } from './state/Store';
|
|||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { Translate, translate } from './i18n/i18n';
|
import { Translate, translate } from './i18n/i18n';
|
||||||
import SongLobbyView from './views/SongLobbyView';
|
import SongLobbyView from './views/SongLobbyView';
|
||||||
import AuthenticationView from './views/AuthenticationView';
|
|
||||||
import StartPageView from './views/StartPageView';
|
import StartPageView from './views/StartPageView';
|
||||||
import HomeView from './views/HomeView';
|
import HomeView from './views/HomeView';
|
||||||
import SearchView from './views/SearchView';
|
import SearchView from './views/SearchView';
|
||||||
@@ -28,6 +27,14 @@ import { Button, Center, VStack } from 'native-base';
|
|||||||
import { unsetAccessToken } from './state/UserSlice';
|
import { unsetAccessToken } from './state/UserSlice';
|
||||||
import TextButton from './components/TextButton';
|
import TextButton from './components/TextButton';
|
||||||
import ErrorView from './views/ErrorView';
|
import ErrorView from './views/ErrorView';
|
||||||
|
import GenreDetailsView from './views/GenreDetailsView';
|
||||||
|
import GoogleView from './views/GoogleView';
|
||||||
|
import VerifiedView from './views/VerifiedView';
|
||||||
|
import SigninView from './views/SigninView';
|
||||||
|
import SignupView from './views/SignupView';
|
||||||
|
import TabNavigation from './components/V2/TabNavigation';
|
||||||
|
import PasswordResetView from './views/PasswordResetView';
|
||||||
|
import ForgotPasswordView from './views/ForgotPasswordView';
|
||||||
|
|
||||||
// Util function to hide route props in URL
|
// Util function to hide route props in URL
|
||||||
const removeMe = () => '';
|
const removeMe = () => '';
|
||||||
@@ -39,6 +46,11 @@ const protectedRoutes = () =>
|
|||||||
options: { title: translate('welcome'), headerLeft: null },
|
options: { title: translate('welcome'), headerLeft: null },
|
||||||
link: '/',
|
link: '/',
|
||||||
},
|
},
|
||||||
|
HomeNew: {
|
||||||
|
component: TabNavigation,
|
||||||
|
options: { headerShown: false },
|
||||||
|
link: '/V2',
|
||||||
|
},
|
||||||
Play: { component: PlayView, options: { title: translate('play') }, link: '/play/:songId' },
|
Play: { component: PlayView, options: { title: translate('play') }, link: '/play/:songId' },
|
||||||
Settings: {
|
Settings: {
|
||||||
component: SetttingsNavigator,
|
component: SetttingsNavigator,
|
||||||
@@ -58,6 +70,11 @@ const protectedRoutes = () =>
|
|||||||
options: { title: translate('artistFilter') },
|
options: { title: translate('artistFilter') },
|
||||||
link: '/artist/:artistId',
|
link: '/artist/:artistId',
|
||||||
},
|
},
|
||||||
|
Genre: {
|
||||||
|
component: GenreDetailsView,
|
||||||
|
options: { title: translate('genreFilter') },
|
||||||
|
link: '/genre/:genreId',
|
||||||
|
},
|
||||||
Score: {
|
Score: {
|
||||||
component: ScoreView,
|
component: ScoreView,
|
||||||
options: { title: translate('score'), headerLeft: null },
|
options: { title: translate('score'), headerLeft: null },
|
||||||
@@ -74,7 +91,12 @@ const protectedRoutes = () =>
|
|||||||
link: undefined,
|
link: undefined,
|
||||||
},
|
},
|
||||||
User: { component: ProfileView, options: { title: translate('user') }, link: '/user' },
|
User: { component: ProfileView, options: { title: translate('user') }, link: '/user' },
|
||||||
} as const);
|
Verified: {
|
||||||
|
component: VerifiedView,
|
||||||
|
options: { title: 'Verify email', headerShown: false },
|
||||||
|
link: '/verify',
|
||||||
|
},
|
||||||
|
}) as const;
|
||||||
|
|
||||||
const publicRoutes = () =>
|
const publicRoutes = () =>
|
||||||
({
|
({
|
||||||
@@ -84,23 +106,31 @@ const publicRoutes = () =>
|
|||||||
link: '/',
|
link: '/',
|
||||||
},
|
},
|
||||||
Login: {
|
Login: {
|
||||||
component: (params: RouteProps<{}>) =>
|
component: SigninView,
|
||||||
AuthenticationView({ isSignup: false, ...params }),
|
options: { title: translate('signInBtn'), headerShown: false },
|
||||||
options: { title: translate('signInBtn') },
|
|
||||||
link: '/login',
|
link: '/login',
|
||||||
},
|
},
|
||||||
Signup: {
|
Signup: {
|
||||||
component: (params: RouteProps<{}>) =>
|
component: SignupView,
|
||||||
AuthenticationView({ isSignup: true, ...params }),
|
options: { title: translate('signUpBtn'), headerShown: false },
|
||||||
options: { title: translate('signUpBtn') },
|
|
||||||
link: '/signup',
|
link: '/signup',
|
||||||
},
|
},
|
||||||
Oops: {
|
Google: {
|
||||||
component: ProfileErrorView,
|
component: GoogleView,
|
||||||
options: { title: 'Oops', headerShown: false },
|
options: { title: 'Google signin', headerShown: false },
|
||||||
link: undefined,
|
link: '/logged/google',
|
||||||
},
|
},
|
||||||
} as const);
|
PasswordReset: {
|
||||||
|
component: PasswordResetView,
|
||||||
|
options: { title: 'Password reset form', headerShown: false },
|
||||||
|
link: '/password_reset',
|
||||||
|
},
|
||||||
|
ForgotPassword: {
|
||||||
|
component: ForgotPasswordView,
|
||||||
|
options: { title: 'Password reset form', headerShown: false },
|
||||||
|
link: '/forgot_password',
|
||||||
|
},
|
||||||
|
}) as const;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
type Route<Props = any> = {
|
type Route<Props = any> = {
|
||||||
@@ -121,19 +151,18 @@ type PrivateRoutesParams = RouteParams<ReturnType<typeof protectedRoutes>>;
|
|||||||
type PublicRoutesParams = RouteParams<ReturnType<typeof publicRoutes>>;
|
type PublicRoutesParams = RouteParams<ReturnType<typeof publicRoutes>>;
|
||||||
type AppRouteParams = PrivateRoutesParams & PublicRoutesParams;
|
type AppRouteParams = PrivateRoutesParams & PublicRoutesParams;
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator<AppRouteParams & { Loading: never }>();
|
const Stack = createNativeStackNavigator<AppRouteParams & { Loading: never; Oops: never }>();
|
||||||
|
|
||||||
const RouteToScreen =
|
const RouteToScreen =
|
||||||
<T extends {}>(component: Route<T>['component']) =>
|
<T extends {}>(component: Route<T>['component']) =>
|
||||||
// eslint-disable-next-line react/display-name
|
// eslint-disable-next-line react/display-name
|
||||||
(props: NativeStackScreenProps<T & ParamListBase>) =>
|
(props: NativeStackScreenProps<T & ParamListBase>) => (
|
||||||
(
|
<>
|
||||||
<>
|
{component({ ...props.route.params, route: props.route } as Parameters<
|
||||||
{component({ ...props.route.params, route: props.route } as Parameters<
|
Route<T>['component']
|
||||||
Route<T>['component']
|
>[0])}
|
||||||
>[0])}
|
</>
|
||||||
</>
|
);
|
||||||
);
|
|
||||||
|
|
||||||
const routesToScreens = (routes: Partial<Record<keyof AppRouteParams, Route>>) =>
|
const routesToScreens = (routes: Partial<Record<keyof AppRouteParams, Route>>) =>
|
||||||
Object.entries(routes).map(([name, route], routeIndex) => (
|
Object.entries(routes).map(([name, route], routeIndex) => (
|
||||||
@@ -170,6 +199,8 @@ const routesToLinkingConfig = (
|
|||||||
|
|
||||||
const ProfileErrorView = (props: { onTryAgain: () => void }) => {
|
const ProfileErrorView = (props: { onTryAgain: () => void }) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Center style={{ flexGrow: 1 }}>
|
<Center style={{ flexGrow: 1 }}>
|
||||||
<VStack space={3}>
|
<VStack space={3}>
|
||||||
@@ -178,7 +209,10 @@ const ProfileErrorView = (props: { onTryAgain: () => void }) => {
|
|||||||
<Translate translationKey="tryAgain" />
|
<Translate translationKey="tryAgain" />
|
||||||
</Button>
|
</Button>
|
||||||
<TextButton
|
<TextButton
|
||||||
onPress={() => dispatch(unsetAccessToken())}
|
onPress={() => {
|
||||||
|
dispatch(unsetAccessToken());
|
||||||
|
navigation.navigate('Start');
|
||||||
|
}}
|
||||||
colorScheme="error"
|
colorScheme="error"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
translate={{ translationKey: 'signOutBtn' }}
|
translate={{ translationKey: 'signOutBtn' }}
|
||||||
@@ -239,12 +273,15 @@ export const Router = () => {
|
|||||||
>
|
>
|
||||||
<Stack.Navigator>
|
<Stack.Navigator>
|
||||||
{authStatus == 'error' ? (
|
{authStatus == 'error' ? (
|
||||||
<Stack.Screen
|
<>
|
||||||
name="Oops"
|
<Stack.Screen
|
||||||
component={RouteToScreen(() => (
|
name="Oops"
|
||||||
<ProfileErrorView onTryAgain={() => userProfile.refetch()} />
|
component={RouteToScreen(() => (
|
||||||
))}
|
<ProfileErrorView onTryAgain={() => userProfile.refetch()} />
|
||||||
/>
|
))}
|
||||||
|
/>
|
||||||
|
{routesToScreens(publicRoutes())}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
routesToScreens(routes)
|
routesToScreens(routes)
|
||||||
)}
|
)}
|
||||||
|
|||||||
143
front/Theme.tsx
@@ -12,63 +12,118 @@ const ThemeProvider = ({ children }: { children: JSX.Element }) => {
|
|||||||
useSystemColorMode: false,
|
useSystemColorMode: false,
|
||||||
initialColorMode: colorScheme,
|
initialColorMode: colorScheme,
|
||||||
},
|
},
|
||||||
|
fonts: {
|
||||||
|
heading: 'Lexend',
|
||||||
|
body: 'Lexend',
|
||||||
|
mono: 'Lexend',
|
||||||
|
},
|
||||||
colors: {
|
colors: {
|
||||||
primary: {
|
primary: {
|
||||||
50: '#e6faea',
|
50: '#eff1fe',
|
||||||
100: '#c8e7d0',
|
100: '#e7eafe',
|
||||||
200: '#a7d6b5',
|
200: '#cdd4fd',
|
||||||
300: '#86c498',
|
300: '#5f74f7',
|
||||||
400: '#65b47c',
|
400: '#5668de',
|
||||||
500: '#4b9a62',
|
500: '#4c5dc6',
|
||||||
600: '#3a784b',
|
600: '#4757b9',
|
||||||
700: '#275635',
|
700: '#394694',
|
||||||
800: '#14341f',
|
800: '#2b346f',
|
||||||
900: '#001405',
|
900: '#212956',
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
50: '#d8ffff',
|
50: '#f7f3ff',
|
||||||
100: '#acffff',
|
100: '#f3edfe',
|
||||||
200: '#7dffff',
|
200: '#e6d9fe',
|
||||||
300: '#4dffff',
|
300: '#ae84fb',
|
||||||
400: '#28ffff',
|
400: '#9d77e2',
|
||||||
500: '#18e5e6',
|
500: '#8b6ac9',
|
||||||
600: '#00b2b3',
|
600: '#8363bc',
|
||||||
700: '#007f80',
|
700: '#684f97',
|
||||||
800: '#004d4e',
|
800: '#4e3b71',
|
||||||
900: '#001b1d',
|
900: '#3d2e58',
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
50: '#ffe2e9',
|
50: '#f7f3ff',
|
||||||
100: '#ffb1bf',
|
100: '#f3edfe',
|
||||||
200: '#ff7f97',
|
200: '#e6d9fe',
|
||||||
300: '#ff4d6d',
|
300: '#ae84fb',
|
||||||
400: '#fe1d43',
|
400: '#9d77e2',
|
||||||
500: '#e5062b',
|
500: '#8b6ac9',
|
||||||
600: '#b30020',
|
600: '#8363bc',
|
||||||
700: '#810017',
|
700: '#684f97',
|
||||||
800: '#4f000c',
|
800: '#4e3b71',
|
||||||
900: '#200004',
|
900: '#3d2e58',
|
||||||
|
},
|
||||||
|
alert: {
|
||||||
|
50: '#fff2f1',
|
||||||
|
100: '#ffebea',
|
||||||
|
200: '#ffd6d3',
|
||||||
|
300: '#ff7a72',
|
||||||
|
400: '#e66e67',
|
||||||
|
500: '#cc625b',
|
||||||
|
600: '#bf5c56',
|
||||||
|
700: '#994944',
|
||||||
|
800: '#733733',
|
||||||
|
900: '#592b28',
|
||||||
},
|
},
|
||||||
notification: {
|
notification: {
|
||||||
50: '#ffe1e1',
|
50: '#fdfbec',
|
||||||
100: '#ffb1b1',
|
100: '#fcf9e2',
|
||||||
200: '#ff7f7f',
|
200: '#f8f3c3',
|
||||||
300: '#ff4c4c',
|
300: '#ead93c',
|
||||||
400: '#ff1a1a',
|
400: '#d3c336',
|
||||||
500: '#e60000',
|
500: '#bbae30',
|
||||||
600: '#b40000',
|
600: '#b0a32d',
|
||||||
700: '#810000',
|
700: '#8c8224',
|
||||||
800: '#500000',
|
800: '#69621b',
|
||||||
900: '#210000',
|
900: '#524c15',
|
||||||
|
},
|
||||||
|
black: {
|
||||||
|
50: '#e7e7e8',
|
||||||
|
100: '#dbdbdc',
|
||||||
|
200: '#b5b5b6',
|
||||||
|
300: '#101014',
|
||||||
|
400: '#0e0e12',
|
||||||
|
500: '#0d0d10',
|
||||||
|
600: '#0c0c0f',
|
||||||
|
700: '#0a0a0c',
|
||||||
|
800: '#070709',
|
||||||
|
900: '#060607',
|
||||||
|
},
|
||||||
|
red: {
|
||||||
|
50: '#fdedee',
|
||||||
|
100: '#fce4e5',
|
||||||
|
200: '#f9c7c9',
|
||||||
|
300: '#ed4a51',
|
||||||
|
400: '#d54349',
|
||||||
|
500: '#be3b41',
|
||||||
|
600: '#b2383d',
|
||||||
|
700: '#8e2c31',
|
||||||
|
800: '#6b2124',
|
||||||
|
900: '#531a1c',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
Button: {
|
Button: {
|
||||||
variants: {
|
baseStyle: () => ({
|
||||||
solid: () => ({
|
borderRadius: 'md',
|
||||||
rounded: 'full',
|
}),
|
||||||
}),
|
},
|
||||||
|
Link: {
|
||||||
|
defaultProps: {
|
||||||
|
isUnderlined: false,
|
||||||
},
|
},
|
||||||
|
baseStyle: () => ({
|
||||||
|
_text: {
|
||||||
|
color: 'secondary.300',
|
||||||
|
},
|
||||||
|
_hover: {
|
||||||
|
isUnderlined: true,
|
||||||
|
_text: {
|
||||||
|
color: 'secondary.400',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
|
|||||||
15
front/android/.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# OSX
|
||||||
|
#
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Android/IntelliJ
|
||||||
|
#
|
||||||
|
build/
|
||||||
|
.idea
|
||||||
|
.gradle
|
||||||
|
local.properties
|
||||||
|
*.iml
|
||||||
|
*.hprof
|
||||||
|
|
||||||
|
# Bundle artifacts
|
||||||
|
*.jsbundle
|
||||||
180
front/android/app/build.gradle
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
apply plugin: "com.android.application"
|
||||||
|
apply plugin: "com.facebook.react"
|
||||||
|
|
||||||
|
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is the configuration block to customize your React Native Android app.
|
||||||
|
* By default you don't need to apply any configuration, just uncomment the lines you need.
|
||||||
|
*/
|
||||||
|
react {
|
||||||
|
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
|
||||||
|
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
||||||
|
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
|
||||||
|
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
||||||
|
|
||||||
|
// Use Expo CLI to bundle the app, this ensures the Metro config
|
||||||
|
// works correctly with Expo projects.
|
||||||
|
cliFile = new File(["node", "--print", "require.resolve('@expo/cli')"].execute(null, rootDir).text.trim())
|
||||||
|
bundleCommand = "export:embed"
|
||||||
|
|
||||||
|
/* Folders */
|
||||||
|
// The root of your project, i.e. where "package.json" lives. Default is '..'
|
||||||
|
// root = file("../")
|
||||||
|
// The folder where the react-native NPM package is. Default is ../node_modules/react-native
|
||||||
|
// reactNativeDir = file("../node_modules/react-native")
|
||||||
|
// The folder where the react-native Codegen package is. Default is ../node_modules/@react-native/codegen
|
||||||
|
// codegenDir = file("../node_modules/@react-native/codegen")
|
||||||
|
|
||||||
|
/* Variants */
|
||||||
|
// The list of variants to that are debuggable. For those we're going to
|
||||||
|
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
|
||||||
|
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
|
||||||
|
// debuggableVariants = ["liteDebug", "prodDebug"]
|
||||||
|
|
||||||
|
/* Bundling */
|
||||||
|
// A list containing the node command and its flags. Default is just 'node'.
|
||||||
|
// nodeExecutableAndArgs = ["node"]
|
||||||
|
|
||||||
|
//
|
||||||
|
// The path to the CLI configuration file. Default is empty.
|
||||||
|
// bundleConfig = file(../rn-cli.config.js)
|
||||||
|
//
|
||||||
|
// The name of the generated asset file containing your JS bundle
|
||||||
|
// bundleAssetName = "MyApplication.android.bundle"
|
||||||
|
//
|
||||||
|
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
|
||||||
|
// entryFile = file("../js/MyApplication.android.js")
|
||||||
|
//
|
||||||
|
// A list of extra flags to pass to the 'bundle' commands.
|
||||||
|
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
|
||||||
|
// extraPackagerArgs = []
|
||||||
|
|
||||||
|
/* Hermes Commands */
|
||||||
|
// The hermes compiler command to run. By default it is 'hermesc'
|
||||||
|
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
|
||||||
|
//
|
||||||
|
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
|
||||||
|
// hermesFlags = ["-O", "-output-source-map"]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
|
||||||
|
*/
|
||||||
|
def enableProguardInReleaseBuilds = (findProperty('android.enableProguardInReleaseBuilds') ?: false).toBoolean()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The preferred build flavor of JavaScriptCore (JSC)
|
||||||
|
*
|
||||||
|
* For example, to use the international variant, you can use:
|
||||||
|
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
|
||||||
|
*
|
||||||
|
* The international variant includes ICU i18n library and necessary data
|
||||||
|
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
|
||||||
|
* give correct results when using with locales other than en-US. Note that
|
||||||
|
* this variant is about 6MiB larger per architecture than default.
|
||||||
|
*/
|
||||||
|
def jscFlavor = 'org.webkit:android-jsc:+'
|
||||||
|
|
||||||
|
android {
|
||||||
|
ndkVersion rootProject.ext.ndkVersion
|
||||||
|
|
||||||
|
compileSdkVersion rootProject.ext.compileSdkVersion
|
||||||
|
|
||||||
|
namespace 'build.apk'
|
||||||
|
defaultConfig {
|
||||||
|
applicationId 'build.apk'
|
||||||
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0.0"
|
||||||
|
|
||||||
|
buildConfigField("boolean", "REACT_NATIVE_UNSTABLE_USE_RUNTIME_SCHEDULER_ALWAYS", (findProperty("reactNative.unstable_useRuntimeSchedulerAlways") ?: true).toString())
|
||||||
|
}
|
||||||
|
signingConfigs {
|
||||||
|
debug {
|
||||||
|
storeFile file('debug.keystore')
|
||||||
|
storePassword 'android'
|
||||||
|
keyAlias 'androiddebugkey'
|
||||||
|
keyPassword 'android'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
signingConfig signingConfigs.debug
|
||||||
|
}
|
||||||
|
release {
|
||||||
|
// Caution! In production, you need to generate your own keystore file.
|
||||||
|
// see https://reactnative.dev/docs/signed-apk-android.
|
||||||
|
signingConfig signingConfigs.debug
|
||||||
|
shrinkResources (findProperty('android.enableShrinkResourcesInReleaseBuilds')?.toBoolean() ?: false)
|
||||||
|
minifyEnabled enableProguardInReleaseBuilds
|
||||||
|
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply static values from `gradle.properties` to the `android.packagingOptions`
|
||||||
|
// Accepts values in comma delimited lists, example:
|
||||||
|
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
|
||||||
|
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
|
||||||
|
// Split option: 'foo,bar' -> ['foo', 'bar']
|
||||||
|
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
|
||||||
|
// Trim all elements in place.
|
||||||
|
for (i in 0..<options.size()) options[i] = options[i].trim();
|
||||||
|
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
|
||||||
|
options -= ""
|
||||||
|
|
||||||
|
if (options.length > 0) {
|
||||||
|
println "android.packagingOptions.$prop += $options ($options.length)"
|
||||||
|
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
|
||||||
|
options.each {
|
||||||
|
android.packagingOptions[prop] += it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// The version of react-native is set by the React Native Gradle Plugin
|
||||||
|
implementation("com.facebook.react:react-android")
|
||||||
|
|
||||||
|
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
|
||||||
|
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
|
||||||
|
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
|
||||||
|
def frescoVersion = rootProject.ext.frescoVersion
|
||||||
|
|
||||||
|
// If your app supports Android versions before Ice Cream Sandwich (API level 14)
|
||||||
|
if (isGifEnabled || isWebpEnabled) {
|
||||||
|
implementation("com.facebook.fresco:fresco:${frescoVersion}")
|
||||||
|
implementation("com.facebook.fresco:imagepipeline-okhttp3:${frescoVersion}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGifEnabled) {
|
||||||
|
// For animated gif support
|
||||||
|
implementation("com.facebook.fresco:animated-gif:${frescoVersion}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWebpEnabled) {
|
||||||
|
// For webp support
|
||||||
|
implementation("com.facebook.fresco:webpsupport:${frescoVersion}")
|
||||||
|
if (isWebpAnimatedEnabled) {
|
||||||
|
// Animated webp support
|
||||||
|
implementation("com.facebook.fresco:animated-webp:${frescoVersion}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}")
|
||||||
|
debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") {
|
||||||
|
exclude group:'com.squareup.okhttp3', module:'okhttp'
|
||||||
|
}
|
||||||
|
debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}")
|
||||||
|
|
||||||
|
if (hermesEnabled.toBoolean()) {
|
||||||
|
implementation("com.facebook.react:hermes-android")
|
||||||
|
} else {
|
||||||
|
implementation jscFlavor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: new File(["node", "--print", "require.resolve('@react-native-community/cli-platform-android/package.json')"].execute(null, rootDir).text.trim(), "../native_modules.gradle");
|
||||||
|
applyNativeModulesAppBuildGradle(project)
|
||||||
BIN
front/android/app/debug.keystore
Normal file
14
front/android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# By default, the flags in this file are appended to flags specified
|
||||||
|
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
|
||||||
|
# You can edit the include path and order by changing the proguardFiles
|
||||||
|
# directive in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# react-native-reanimated
|
||||||
|
-keep class com.swmansion.reanimated.** { *; }
|
||||||
|
-keep class com.facebook.react.turbomodule.** { *; }
|
||||||
|
|
||||||
|
# Add any project specific keep options here:
|
||||||
7
front/android/app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||||
|
|
||||||
|
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
|
||||||
|
</manifest>
|
||||||
33
front/android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
|
<data android:scheme="https"/>
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:allowBackup="true" android:theme="@style/AppTheme">
|
||||||
|
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
|
||||||
|
<meta-data android:name="expo.modules.updates.EXPO_SDK_VERSION" android:value="49.0.0"/>
|
||||||
|
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||||
|
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
||||||
|
<activity android:name=".MainActivity" android:label="@string/app_name" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
|
<data android:scheme="build.apk"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" android:exported="false"/>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Copyright (C) 2014 The Android Open Source Project
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
|
||||||
|
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
|
||||||
|
android:insetTop="@dimen/abc_edit_text_inset_top_material"
|
||||||
|
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material">
|
||||||
|
|
||||||
|
<selector>
|
||||||
|
<!--
|
||||||
|
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
|
||||||
|
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
|
||||||
|
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
|
||||||
|
|
||||||
|
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||||
|
|
||||||
|
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
|
||||||
|
-->
|
||||||
|
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||||
|
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
|
||||||
|
</selector>
|
||||||
|
|
||||||
|
</inset>
|
||||||
3
front/android/app/src/main/res/drawable/splashscreen.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@color/splashscreen_background"/>
|
||||||
|
</layer-list>
|
||||||
BIN
front/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
BIN
front/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
front/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
BIN
front/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
front/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
BIN
front/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 14 KiB |
BIN
front/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 21 KiB |
1
front/android/app/src/main/res/values-night/colors.xml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<resources/>
|
||||||