feat: bootstrap NestJS backend with auth, stations, users, health-check, now-playing
This commit is contained in:
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
coverage
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.log
|
||||||
25
.env.example
Normal file
25
.env.example
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Database
|
||||||
|
DATABASE_URL=postgresql://radiola:radiola_pass@localhost:5432/radiola?schema=public
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# SMTP (for magic links)
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=noreply@example.com
|
||||||
|
SMTP_PASS=your-smtp-password
|
||||||
|
MAIL_FROM=radiOLA <noreply@example.com>
|
||||||
|
|
||||||
|
# App
|
||||||
|
FRONTEND_URL=https://radiola.app
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# Postgres (for docker-compose)
|
||||||
|
POSTGRES_USER=radiola
|
||||||
|
POSTGRES_PASSWORD=radiola_pass
|
||||||
|
POSTGRES_DB=radiola
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
coverage
|
||||||
|
.DS_Store
|
||||||
4
.prettierrc
Normal file
4
.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npx prisma generate
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --only=production && npm cache clean --force
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/src/main"]
|
||||||
98
README.md
Normal file
98
README.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||||
|
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||||
|
|
||||||
|
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||||
|
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||||
|
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||||
|
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||||
|
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||||
|
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||||
|
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||||
|
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||||
|
</p>
|
||||||
|
<!--[](https://opencollective.com/nest#backer)
|
||||||
|
[](https://opencollective.com/nest#sponsor)-->
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||||
|
|
||||||
|
## Project setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compile and run the project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# development
|
||||||
|
$ npm run start
|
||||||
|
|
||||||
|
# watch mode
|
||||||
|
$ npm run start:dev
|
||||||
|
|
||||||
|
# production mode
|
||||||
|
$ npm run start:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# unit tests
|
||||||
|
$ npm run test
|
||||||
|
|
||||||
|
# e2e tests
|
||||||
|
$ npm run test:e2e
|
||||||
|
|
||||||
|
# test coverage
|
||||||
|
$ npm run test:cov
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||||
|
|
||||||
|
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm install -g @nestjs/mau
|
||||||
|
$ mau deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
Check out a few resources that may come in handy when working with NestJS:
|
||||||
|
|
||||||
|
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||||
|
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||||
|
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||||
|
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
||||||
|
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||||
|
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||||
|
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||||
|
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||||
|
|
||||||
|
## Stay in touch
|
||||||
|
|
||||||
|
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
||||||
|
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||||
|
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
||||||
68
docker-compose.yml
Normal file
68
docker-compose.yml
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: radiola-app
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- DATABASE_URL=postgresql://${POSTGRES_USER:-radiola}:${POSTGRES_PASSWORD:-radiola_pass}@postgres:5432/${POSTGRES_DB:-radiola}?schema=public
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||||
|
- SMTP_HOST=${SMTP_HOST}
|
||||||
|
- SMTP_PORT=${SMTP_PORT:-587}
|
||||||
|
- SMTP_USER=${SMTP_USER}
|
||||||
|
- SMTP_PASS=${SMTP_PASS}
|
||||||
|
- MAIL_FROM=${MAIL_FROM:-noreply@radiola.app}
|
||||||
|
- FRONTEND_URL=${FRONTEND_URL:-https://radiola.app}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
networks:
|
||||||
|
- radiola
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: radiola-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=${POSTGRES_USER:-radiola}
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-radiola_pass}
|
||||||
|
- POSTGRES_DB=${POSTGRES_DB:-radiola}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-radiola} -d ${POSTGRES_DB:-radiola}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- radiola
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: radiola-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:6379:6379"
|
||||||
|
networks:
|
||||||
|
- radiola
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
radiola:
|
||||||
|
driver: bridge
|
||||||
35
eslint.config.mjs
Normal file
35
eslint.config.mjs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// @ts-check
|
||||||
|
import eslint from '@eslint/js';
|
||||||
|
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['eslint.config.mjs'],
|
||||||
|
},
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
eslintPluginPrettierRecommended,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
...globals.jest,
|
||||||
|
},
|
||||||
|
sourceType: 'commonjs',
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
|
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||||
|
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
8
nest-cli.json
Normal file
8
nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
44
nginx.conf
Normal file
44
nginx.conf
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|
||||||
|
upstream app {
|
||||||
|
server app:3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://app;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://app;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11464
package-lock.json
generated
Normal file
11464
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
88
package.json
Normal file
88
package.json
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
{
|
||||||
|
"name": "radiola-backend",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "radiOLA backend API",
|
||||||
|
"author": "",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:deploy": "prisma migrate deploy",
|
||||||
|
"db:studio": "prisma studio",
|
||||||
|
"db:seed": "ts-node prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^11.0.1",
|
||||||
|
"@nestjs/config": "^4.0.0",
|
||||||
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/jwt": "^11.0.0",
|
||||||
|
"@nestjs/passport": "^11.0.0",
|
||||||
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"@nestjs/platform-socket.io": "^11.0.1",
|
||||||
|
"@nestjs/schedule": "^5.0.0",
|
||||||
|
"@nestjs/swagger": "^11.0.0",
|
||||||
|
"@nestjs/websockets": "^11.0.1",
|
||||||
|
"@prisma/client": "^6.2.0",
|
||||||
|
"bullmq": "^5.34.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
|
"ioredis": "^5.4.2",
|
||||||
|
"nanoid": "^3.3.8",
|
||||||
|
"nodemailer": "^6.9.16",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
"@eslint/js": "^9.18.0",
|
||||||
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
"@nestjs/schematics": "^11.0.0",
|
||||||
|
"@nestjs/testing": "^11.0.1",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/nodemailer": "^6.4.17",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@types/supertest": "^7.0.0",
|
||||||
|
"eslint": "^9.18.0",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
|
"globals": "^17.0.0",
|
||||||
|
"jest": "^30.0.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"prisma": "^6.2.0",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"ts-loader": "^9.5.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"typescript-eslint": "^8.20.0"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": ["**/*.(t|j)s"],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
112
prisma/schema.prisma
Normal file
112
prisma/schema.prisma
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
email String @unique
|
||||||
|
name String?
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
favorites UserFavorite[]
|
||||||
|
history PlayHistory[]
|
||||||
|
settings UserSettings?
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
model MagicLinkToken {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
email String
|
||||||
|
token String @unique
|
||||||
|
expiresAt DateTime @map("expires_at")
|
||||||
|
usedAt DateTime? @map("used_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([token])
|
||||||
|
@@index([email])
|
||||||
|
@@map("magic_link_tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Station {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
stationId Int @unique @map("station_id")
|
||||||
|
name String
|
||||||
|
prefix String
|
||||||
|
streamUrl String @map("stream_url")
|
||||||
|
coverUrl String? @map("cover_url")
|
||||||
|
genre String?
|
||||||
|
tags String[]
|
||||||
|
sortOrder Int @map("sort_order")
|
||||||
|
source String // "record" | "local"
|
||||||
|
isOnline Boolean @default(true) @map("is_online")
|
||||||
|
lastCheckAt DateTime? @map("last_check_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
favorites UserFavorite[]
|
||||||
|
history PlayHistory[]
|
||||||
|
nowPlaying NowPlaying?
|
||||||
|
|
||||||
|
@@index([isOnline])
|
||||||
|
@@index([source])
|
||||||
|
@@map("stations")
|
||||||
|
}
|
||||||
|
|
||||||
|
model NowPlaying {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
stationId String @unique @map("station_id")
|
||||||
|
station Station @relation(fields: [stationId], references: [id], onDelete: Cascade)
|
||||||
|
song String
|
||||||
|
artist String
|
||||||
|
coverUrl String? @map("cover_url")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@map("now_playing")
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserFavorite {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
stationId String @map("station_id")
|
||||||
|
station Station @relation(fields: [stationId], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@unique([userId, stationId])
|
||||||
|
@@index([userId])
|
||||||
|
@@map("user_favorites")
|
||||||
|
}
|
||||||
|
|
||||||
|
model PlayHistory {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
stationId String @map("station_id")
|
||||||
|
station Station @relation(fields: [stationId], references: [id], onDelete: Cascade)
|
||||||
|
playedAt DateTime @default(now()) @map("played_at")
|
||||||
|
|
||||||
|
@@index([userId, playedAt])
|
||||||
|
@@map("play_history")
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserSettings {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique @map("user_id")
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
theme String @default("system")
|
||||||
|
language String @default("ru")
|
||||||
|
autoPlay Boolean @default(false) @map("auto_play")
|
||||||
|
showOffline Boolean @default(true) @map("show_offline")
|
||||||
|
sleepTimerMinutes Int? @map("sleep_timer_minutes")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@map("user_settings")
|
||||||
|
}
|
||||||
64
prisma/seed.js
Normal file
64
prisma/seed.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const stationsPath = path.join(__dirname, 'stations.json');
|
||||||
|
|
||||||
|
if (!fs.existsSync(stationsPath)) {
|
||||||
|
console.warn(`Stations file not found at ${stationsPath}. Skipping seed.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = fs.readFileSync(stationsPath, 'utf-8');
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
|
||||||
|
const groups = {};
|
||||||
|
for (const group of data.groups || []) {
|
||||||
|
groups[group.id] = group.name || 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
const stations = data.stations || [];
|
||||||
|
console.log(`Seeding ${stations.length} stations...`);
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
for (const s of stations) {
|
||||||
|
const groupName = groups[s.groupId] || 'Unknown';
|
||||||
|
await prisma.station.upsert({
|
||||||
|
where: { stationId: s.id },
|
||||||
|
update: {
|
||||||
|
name: s.name,
|
||||||
|
streamUrl: s.stream,
|
||||||
|
genre: groupName,
|
||||||
|
tags: [groupName],
|
||||||
|
sortOrder: count,
|
||||||
|
source: 'local',
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
stationId: s.id,
|
||||||
|
name: s.name,
|
||||||
|
prefix: '',
|
||||||
|
streamUrl: s.stream,
|
||||||
|
coverUrl: null,
|
||||||
|
genre: groupName,
|
||||||
|
tags: [groupName],
|
||||||
|
sortOrder: count,
|
||||||
|
source: 'local',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Seeded ${count} stations.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
64
prisma/seed.ts
Normal file
64
prisma/seed.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const stationsPath = path.join(__dirname, 'stations.json');
|
||||||
|
|
||||||
|
if (!fs.existsSync(stationsPath)) {
|
||||||
|
console.warn(`Stations file not found at ${stationsPath}. Skipping seed.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = fs.readFileSync(stationsPath, 'utf-8');
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
|
||||||
|
const groups: Record<number, string> = {};
|
||||||
|
for (const group of data.groups || []) {
|
||||||
|
groups[group.id] = group.name || 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
const stations = data.stations || [];
|
||||||
|
console.log(`Seeding ${stations.length} stations...`);
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
for (const s of stations) {
|
||||||
|
const groupName = groups[s.groupId] || 'Unknown';
|
||||||
|
await prisma.station.upsert({
|
||||||
|
where: { stationId: s.id },
|
||||||
|
update: {
|
||||||
|
name: s.name,
|
||||||
|
streamUrl: s.stream,
|
||||||
|
genre: groupName,
|
||||||
|
tags: [groupName],
|
||||||
|
sortOrder: count,
|
||||||
|
source: 'local',
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
stationId: s.id,
|
||||||
|
name: s.name,
|
||||||
|
prefix: '',
|
||||||
|
streamUrl: s.stream,
|
||||||
|
coverUrl: null,
|
||||||
|
genre: groupName,
|
||||||
|
tags: [groupName],
|
||||||
|
sortOrder: count,
|
||||||
|
source: 'local',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Seeded ${count} stations.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
30
scripts/deploy.sh
Normal file
30
scripts/deploy.sh
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
echo "=== radiOLA Backend Deploy ==="
|
||||||
|
|
||||||
|
# Pull latest changes if in git repo
|
||||||
|
if [ -d .git ]; then
|
||||||
|
git pull origin main || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure .env exists
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo "Warning: .env not found. Copying from .env.example"
|
||||||
|
cp .env.example .env
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build and start
|
||||||
|
echo "Building Docker images..."
|
||||||
|
docker compose build --no-cache
|
||||||
|
|
||||||
|
echo "Starting services..."
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
echo "Running database migrations..."
|
||||||
|
docker compose exec -T app npx prisma migrate deploy
|
||||||
|
|
||||||
|
echo "=== Deploy complete ==="
|
||||||
|
echo "API: http://$(curl -s ifconfig.me || echo 'localhost')"
|
||||||
22
src/app.controller.spec.ts
Normal file
22
src/app.controller.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AppController } from './app.controller';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
|
describe('AppController', () => {
|
||||||
|
let appController: AppController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const app: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AppController],
|
||||||
|
providers: [AppService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
appController = app.get<AppController>(AppController);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('root', () => {
|
||||||
|
it('should return "Hello World!"', () => {
|
||||||
|
expect(appController.getHello()).toBe('Hello World!');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
12
src/app.controller.ts
Normal file
12
src/app.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class AppController {
|
||||||
|
constructor(private readonly appService: AppService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
getHello(): string {
|
||||||
|
return this.appService.getHello();
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/app.module.ts
Normal file
23
src/app.module.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { StationsModule } from './stations/stations.module';
|
||||||
|
import { UsersModule } from './users/users.module';
|
||||||
|
import { NowPlayingModule } from './now-playing/now-playing.module';
|
||||||
|
import { HealthCheckModule } from './health-check/health-check.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
|
PrismaModule,
|
||||||
|
AuthModule,
|
||||||
|
StationsModule,
|
||||||
|
UsersModule,
|
||||||
|
NowPlayingModule,
|
||||||
|
HealthCheckModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
8
src/app.service.ts
Normal file
8
src/app.service.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppService {
|
||||||
|
getHello(): string {
|
||||||
|
return 'Hello World!';
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/auth/auth.controller.ts
Normal file
25
src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Body, Controller, Post, HttpCode, HttpStatus } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { RequestMagicLinkDto } from './dto/request-magic-link.dto';
|
||||||
|
import { VerifyMagicLinkDto } from './dto/verify-magic-link.dto';
|
||||||
|
|
||||||
|
@ApiTags('auth')
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@Post('magic-link')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Request magic link code' })
|
||||||
|
async requestMagicLink(@Body() dto: RequestMagicLinkDto) {
|
||||||
|
return this.authService.requestMagicLink(dto.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('verify')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Verify magic link code and get JWT' })
|
||||||
|
async verify(@Body() dto: VerifyMagicLinkDto) {
|
||||||
|
return this.authService.verifyMagicLink(dto.email, dto.code);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/auth/auth.guard.ts
Normal file
42
src/auth/auth.guard.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
const token = this.extractTokenFromHeader(request);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new UnauthorizedException('Access token missing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await this.jwtService.verifyAsync(token, {
|
||||||
|
secret: this.config.get<string>('JWT_SECRET'),
|
||||||
|
});
|
||||||
|
request['user'] = payload;
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('Invalid access token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractTokenFromHeader(request: Request): string | undefined {
|
||||||
|
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||||
|
return type === 'Bearer' ? token : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/auth/auth.module.ts
Normal file
25
src/auth/auth.module.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthGuard } from './auth.guard';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
secret: config.get<string>('JWT_SECRET'),
|
||||||
|
signOptions: {
|
||||||
|
expiresIn: config.get<string>('JWT_EXPIRES_IN', '7d') as any,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, AuthGuard],
|
||||||
|
exports: [AuthService, AuthGuard, JwtModule],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
87
src/auth/auth.service.ts
Normal file
87
src/auth/auth.service.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
BadRequestException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async requestMagicLink(email: string) {
|
||||||
|
const code = nanoid(6).toUpperCase();
|
||||||
|
const expiresInMinutes = 15;
|
||||||
|
const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000);
|
||||||
|
|
||||||
|
await this.prisma.magicLinkToken.create({
|
||||||
|
data: {
|
||||||
|
email: email.toLowerCase().trim(),
|
||||||
|
token: code,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: integrate real email service (SMTP, SendGrid, etc.)
|
||||||
|
console.log(`Magic link code for ${email}: ${code}`);
|
||||||
|
|
||||||
|
return { message: 'Check your email for the verification code.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyMagicLink(email: string, code: string) {
|
||||||
|
const token = await this.prisma.magicLinkToken.findFirst({
|
||||||
|
where: {
|
||||||
|
email: email.toLowerCase().trim(),
|
||||||
|
token: code.toUpperCase(),
|
||||||
|
usedAt: null,
|
||||||
|
expiresAt: { gt: new Date() },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new UnauthorizedException('Invalid or expired code.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.magicLinkToken.update({
|
||||||
|
where: { id: token.id },
|
||||||
|
data: { usedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
let user = await this.prisma.user.findUnique({
|
||||||
|
where: { email: token.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
user = await this.prisma.user.create({
|
||||||
|
data: { email: token.email },
|
||||||
|
});
|
||||||
|
await this.prisma.userSettings.create({
|
||||||
|
data: { userId: user.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = { sub: user.id, email: user.email };
|
||||||
|
const accessToken = this.jwtService.sign(payload);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateUser(userId: string) {
|
||||||
|
return this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/auth/dto/request-magic-link.dto.ts
Normal file
8
src/auth/dto/request-magic-link.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { IsEmail } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class RequestMagicLinkDto {
|
||||||
|
@ApiProperty({ example: 'user@example.com' })
|
||||||
|
@IsEmail()
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
13
src/auth/dto/verify-magic-link.dto.ts
Normal file
13
src/auth/dto/verify-magic-link.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { IsEmail, IsString, Length } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class VerifyMagicLinkDto {
|
||||||
|
@ApiProperty({ example: 'user@example.com' })
|
||||||
|
@IsEmail()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '123456' })
|
||||||
|
@IsString()
|
||||||
|
@Length(6, 6)
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
7
src/health-check/health-check.module.ts
Normal file
7
src/health-check/health-check.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HealthCheckService } from './health-check.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [HealthCheckService],
|
||||||
|
})
|
||||||
|
export class HealthCheckModule {}
|
||||||
72
src/health-check/health-check.service.ts
Normal file
72
src/health-check/health-check.service.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HealthCheckService {
|
||||||
|
private readonly logger = new Logger(HealthCheckService.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
@Cron(CronExpression.EVERY_HOUR)
|
||||||
|
async checkAllStations() {
|
||||||
|
this.logger.log('Starting hourly station health check...');
|
||||||
|
const stations = await this.prisma.station.findMany();
|
||||||
|
let onlineCount = 0;
|
||||||
|
let offlineCount = 0;
|
||||||
|
|
||||||
|
for (const station of stations) {
|
||||||
|
try {
|
||||||
|
const isOnline = await this.checkStation(station.streamUrl);
|
||||||
|
await this.prisma.station.update({
|
||||||
|
where: { id: station.id },
|
||||||
|
data: { isOnline, lastCheckAt: new Date() },
|
||||||
|
});
|
||||||
|
if (isOnline) onlineCount++;
|
||||||
|
else offlineCount++;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to check station ${station.name}: ${error.message}`,
|
||||||
|
);
|
||||||
|
await this.prisma.station.update({
|
||||||
|
where: { id: station.id },
|
||||||
|
data: { isOnline: false, lastCheckAt: new Date() },
|
||||||
|
});
|
||||||
|
offlineCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Health check complete. Online: ${onlineCount}, Offline: ${offlineCount}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkStation(url: string): Promise<boolean> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'HEAD',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timeout);
|
||||||
|
return response.status >= 200 && response.status < 400;
|
||||||
|
} catch {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
// Fallback to GET if HEAD fails
|
||||||
|
try {
|
||||||
|
const controller2 = new AbortController();
|
||||||
|
const timeout2 = setTimeout(() => controller2.abort(), 10000);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
signal: controller2.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timeout2);
|
||||||
|
return response.status >= 200 && response.status < 400;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/main.ts
Normal file
35
src/main.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.enableCors({
|
||||||
|
origin: process.env.FRONTEND_URL || '*',
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('radiOLA API')
|
||||||
|
.setDescription('radiOLA backend API')
|
||||||
|
.setVersion('0.1')
|
||||||
|
.addBearerAuth()
|
||||||
|
.build();
|
||||||
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
SwaggerModule.setup('api', app, document);
|
||||||
|
|
||||||
|
const port = process.env.PORT || 3000;
|
||||||
|
await app.listen(port);
|
||||||
|
console.log(`Application is running on: http://localhost:${port}`);
|
||||||
|
}
|
||||||
|
bootstrap();
|
||||||
37
src/now-playing/now-playing.gateway.ts
Normal file
37
src/now-playing/now-playing.gateway.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
WebSocketGateway,
|
||||||
|
WebSocketServer,
|
||||||
|
OnGatewayConnection,
|
||||||
|
OnGatewayDisconnect,
|
||||||
|
} from '@nestjs/websockets';
|
||||||
|
import { Server, Socket } from 'socket.io';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
@WebSocketGateway({
|
||||||
|
cors: { origin: '*' },
|
||||||
|
namespace: 'now-playing',
|
||||||
|
})
|
||||||
|
export class NowPlayingGateway
|
||||||
|
implements OnGatewayConnection, OnGatewayDisconnect
|
||||||
|
{
|
||||||
|
@WebSocketServer()
|
||||||
|
server: Server;
|
||||||
|
|
||||||
|
private readonly logger = new Logger(NowPlayingGateway.name);
|
||||||
|
|
||||||
|
handleConnection(client: Socket) {
|
||||||
|
this.logger.log(`Client connected: ${client.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDisconnect(client: Socket) {
|
||||||
|
this.logger.log(`Client disconnected: ${client.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastNowPlaying(stationId: string, data: any) {
|
||||||
|
this.server.emit('now-playing', { stationId, ...data });
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastToRoom(room: string, event: string, data: any) {
|
||||||
|
this.server.to(room).emit(event, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/now-playing/now-playing.module.ts
Normal file
9
src/now-playing/now-playing.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { NowPlayingGateway } from './now-playing.gateway';
|
||||||
|
import { NowPlayingService } from './now-playing.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [NowPlayingGateway, NowPlayingService],
|
||||||
|
exports: [NowPlayingService],
|
||||||
|
})
|
||||||
|
export class NowPlayingModule {}
|
||||||
54
src/now-playing/now-playing.service.ts
Normal file
54
src/now-playing/now-playing.service.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { NowPlayingGateway } from './now-playing.gateway';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NowPlayingService {
|
||||||
|
private readonly logger = new Logger(NowPlayingService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly gateway: NowPlayingGateway,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async updateNowPlaying(
|
||||||
|
stationId: string,
|
||||||
|
data: { song: string; artist: string; coverUrl?: string },
|
||||||
|
) {
|
||||||
|
const nowPlaying = await this.prisma.nowPlaying.upsert({
|
||||||
|
where: { stationId },
|
||||||
|
create: {
|
||||||
|
stationId,
|
||||||
|
song: data.song,
|
||||||
|
artist: data.artist,
|
||||||
|
coverUrl: data.coverUrl,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
song: data.song,
|
||||||
|
artist: data.artist,
|
||||||
|
coverUrl: data.coverUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.gateway.broadcastNowPlaying(stationId, {
|
||||||
|
song: data.song,
|
||||||
|
artist: data.artist,
|
||||||
|
coverUrl: data.coverUrl,
|
||||||
|
updatedAt: nowPlaying.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return nowPlaying;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNowPlaying(stationId: string) {
|
||||||
|
return this.prisma.nowPlaying.findUnique({
|
||||||
|
where: { stationId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllNowPlaying() {
|
||||||
|
return this.prisma.nowPlaying.findMany({
|
||||||
|
include: { station: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/prisma/prisma.module.ts
Normal file
9
src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module, Global } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [PrismaService],
|
||||||
|
exports: [PrismaService],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
16
src/prisma/prisma.service.ts
Normal file
16
src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaService
|
||||||
|
extends PrismaClient
|
||||||
|
implements OnModuleInit, OnModuleDestroy
|
||||||
|
{
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.$connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/stations/dto/create-station.dto.ts
Normal file
44
src/stations/dto/create-station.dto.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { IsInt, IsString, IsOptional, IsArray } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class CreateStationDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsInt()
|
||||||
|
stationId: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
prefix: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
streamUrl: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
coverUrl?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
genre?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, type: [String] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsInt()
|
||||||
|
sortOrder: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
4
src/stations/dto/update-station.dto.ts
Normal file
4
src/stations/dto/update-station.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateStationDto } from './create-station.dto';
|
||||||
|
|
||||||
|
export class UpdateStationDto extends PartialType(CreateStationDto) {}
|
||||||
70
src/stations/stations.controller.ts
Normal file
70
src/stations/stations.controller.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { StationsService } from './stations.service';
|
||||||
|
import { CreateStationDto } from './dto/create-station.dto';
|
||||||
|
import { UpdateStationDto } from './dto/update-station.dto';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard';
|
||||||
|
|
||||||
|
@ApiTags('stations')
|
||||||
|
@Controller('stations')
|
||||||
|
export class StationsController {
|
||||||
|
constructor(private readonly stationsService: StationsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List all stations' })
|
||||||
|
async findAll(
|
||||||
|
@Query('search') search?: string,
|
||||||
|
@Query('source') source?: string,
|
||||||
|
@Query('online') online?: string,
|
||||||
|
) {
|
||||||
|
return this.stationsService.findAll({
|
||||||
|
search,
|
||||||
|
source,
|
||||||
|
online: online === 'true' ? true : online === 'false' ? false : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get station by ID' })
|
||||||
|
async findOne(@Param('id') id: string) {
|
||||||
|
return this.stationsService.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Create station (admin)' })
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
async create(@Body() dto: CreateStationDto) {
|
||||||
|
return this.stationsService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Update station (admin)' })
|
||||||
|
async update(@Param('id') id: string, @Body() dto: UpdateStationDto) {
|
||||||
|
return this.stationsService.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Delete station (admin)' })
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async remove(@Param('id') id: string) {
|
||||||
|
await this.stationsService.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/stations/stations.module.ts
Normal file
12
src/stations/stations.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { StationsService } from './stations.service';
|
||||||
|
import { StationsController } from './stations.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
|
controllers: [StationsController],
|
||||||
|
providers: [StationsService],
|
||||||
|
exports: [StationsService],
|
||||||
|
})
|
||||||
|
export class StationsModule {}
|
||||||
114
src/stations/stations.service.ts
Normal file
114
src/stations/stations.service.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateStationDto } from './dto/create-station.dto';
|
||||||
|
import { UpdateStationDto } from './dto/update-station.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StationsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findAll(filters: {
|
||||||
|
search?: string;
|
||||||
|
source?: string;
|
||||||
|
online?: boolean;
|
||||||
|
}) {
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (filters.source) {
|
||||||
|
where.source = filters.source;
|
||||||
|
}
|
||||||
|
if (filters.online !== undefined) {
|
||||||
|
where.isOnline = filters.online;
|
||||||
|
}
|
||||||
|
if (filters.search) {
|
||||||
|
where.OR = [
|
||||||
|
{ name: { contains: filters.search, mode: 'insensitive' } },
|
||||||
|
{ genre: { contains: filters.search, mode: 'insensitive' } },
|
||||||
|
{ tags: { has: filters.search } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.station.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
include: { nowPlaying: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string) {
|
||||||
|
const station = await this.prisma.station.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { nowPlaying: true },
|
||||||
|
});
|
||||||
|
if (!station) throw new NotFoundException('Station not found');
|
||||||
|
return station;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByStationId(stationId: number) {
|
||||||
|
return this.prisma.station.findUnique({
|
||||||
|
where: { stationId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateStationDto) {
|
||||||
|
return this.prisma.station.create({
|
||||||
|
data: {
|
||||||
|
stationId: dto.stationId,
|
||||||
|
name: dto.name,
|
||||||
|
prefix: dto.prefix,
|
||||||
|
streamUrl: dto.streamUrl,
|
||||||
|
coverUrl: dto.coverUrl,
|
||||||
|
genre: dto.genre,
|
||||||
|
tags: dto.tags ?? [],
|
||||||
|
sortOrder: dto.sortOrder,
|
||||||
|
source: dto.source,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateStationDto) {
|
||||||
|
return this.prisma.station.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...dto,
|
||||||
|
tags: dto.tags,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string) {
|
||||||
|
return this.prisma.station.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertMany(stations: CreateStationDto[]) {
|
||||||
|
const results = [];
|
||||||
|
for (const dto of stations) {
|
||||||
|
const result = await this.prisma.station.upsert({
|
||||||
|
where: { stationId: dto.stationId },
|
||||||
|
update: {
|
||||||
|
name: dto.name,
|
||||||
|
prefix: dto.prefix,
|
||||||
|
streamUrl: dto.streamUrl,
|
||||||
|
coverUrl: dto.coverUrl,
|
||||||
|
genre: dto.genre,
|
||||||
|
tags: dto.tags ?? [],
|
||||||
|
sortOrder: dto.sortOrder,
|
||||||
|
source: dto.source,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
stationId: dto.stationId,
|
||||||
|
name: dto.name,
|
||||||
|
prefix: dto.prefix,
|
||||||
|
streamUrl: dto.streamUrl,
|
||||||
|
coverUrl: dto.coverUrl,
|
||||||
|
genre: dto.genre,
|
||||||
|
tags: dto.tags ?? [],
|
||||||
|
sortOrder: dto.sortOrder,
|
||||||
|
source: dto.source,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/users/dto/update-settings.dto.ts
Normal file
35
src/users/dto/update-settings.dto.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { IsOptional, IsString, IsBoolean, IsInt, Min } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class UpdateSettingsDto {
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
theme?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
language?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
autoPlay?: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
showOffline?: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
sleepTimerMinutes?: number;
|
||||||
|
}
|
||||||
119
src/users/users.controller.ts
Normal file
119
src/users/users.controller.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
Req,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
import { UpdateSettingsDto } from './dto/update-settings.dto';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
|
||||||
|
@ApiTags('users')
|
||||||
|
@Controller('users')
|
||||||
|
export class UsersController {
|
||||||
|
constructor(private readonly usersService: UsersService) {}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Get current user profile' })
|
||||||
|
async me(@Req() req: Request) {
|
||||||
|
const user = req['user'] as { sub: string; email: string };
|
||||||
|
return this.usersService.findById(user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me/settings')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Get user settings' })
|
||||||
|
async getSettings(@Req() req: Request) {
|
||||||
|
const user = req['user'] as { sub: string; email: string };
|
||||||
|
return this.usersService.getSettings(user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('me/settings')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Update user settings' })
|
||||||
|
async updateSettings(
|
||||||
|
@Req() req: Request,
|
||||||
|
@Body() dto: UpdateSettingsDto,
|
||||||
|
) {
|
||||||
|
const user = req['user'] as { sub: string; email: string };
|
||||||
|
return this.usersService.updateSettings(user.sub, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me/favorites')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Get user favorites' })
|
||||||
|
async getFavorites(@Req() req: Request) {
|
||||||
|
const user = req['user'] as { sub: string; email: string };
|
||||||
|
return this.usersService.getFavorites(user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('me/favorites/:stationId')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Add station to favorites' })
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
async addFavorite(
|
||||||
|
@Req() req: Request,
|
||||||
|
@Param('stationId') stationId: string,
|
||||||
|
) {
|
||||||
|
const user = req['user'] as { sub: string; email: string };
|
||||||
|
return this.usersService.addFavorite(user.sub, stationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('me/favorites/:stationId')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Remove station from favorites' })
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async removeFavorite(
|
||||||
|
@Req() req: Request,
|
||||||
|
@Param('stationId') stationId: string,
|
||||||
|
) {
|
||||||
|
const user = req['user'] as { sub: string; email: string };
|
||||||
|
await this.usersService.removeFavorite(user.sub, stationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me/history')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Get play history' })
|
||||||
|
async getHistory(
|
||||||
|
@Req() req: Request,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
@Query('offset') offset?: string,
|
||||||
|
) {
|
||||||
|
const user = req['user'] as { sub: string; email: string };
|
||||||
|
return this.usersService.getHistory(user.sub, {
|
||||||
|
limit: limit ? parseInt(limit, 10) : 50,
|
||||||
|
offset: offset ? parseInt(offset, 10) : 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('me/history/:stationId')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Add play to history' })
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
async addHistory(
|
||||||
|
@Req() req: Request,
|
||||||
|
@Param('stationId') stationId: string,
|
||||||
|
) {
|
||||||
|
const user = req['user'] as { sub: string; email: string };
|
||||||
|
return this.usersService.addHistory(user.sub, stationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/users/users.module.ts
Normal file
12
src/users/users.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
import { UsersController } from './users.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
|
controllers: [UsersController],
|
||||||
|
providers: [UsersService],
|
||||||
|
exports: [UsersService],
|
||||||
|
})
|
||||||
|
export class UsersModule {}
|
||||||
104
src/users/users.service.ts
Normal file
104
src/users/users.service.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { UpdateSettingsDto } from './dto/update-settings.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findById(id: string) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { settings: true },
|
||||||
|
});
|
||||||
|
if (!user) throw new NotFoundException('User not found');
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSettings(userId: string) {
|
||||||
|
const settings = await this.prisma.userSettings.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
if (!settings) {
|
||||||
|
return this.prisma.userSettings.create({
|
||||||
|
data: { userId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSettings(userId: string, dto: UpdateSettingsDto) {
|
||||||
|
await this.findById(userId);
|
||||||
|
return this.prisma.userSettings.upsert({
|
||||||
|
where: { userId },
|
||||||
|
create: {
|
||||||
|
userId,
|
||||||
|
theme: dto.theme,
|
||||||
|
language: dto.language,
|
||||||
|
autoPlay: dto.autoPlay,
|
||||||
|
showOffline: dto.showOffline,
|
||||||
|
sleepTimerMinutes: dto.sleepTimerMinutes,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
theme: dto.theme,
|
||||||
|
language: dto.language,
|
||||||
|
autoPlay: dto.autoPlay,
|
||||||
|
showOffline: dto.showOffline,
|
||||||
|
sleepTimerMinutes: dto.sleepTimerMinutes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFavorites(userId: string) {
|
||||||
|
const favorites = await this.prisma.userFavorite.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: { station: { include: { nowPlaying: true } } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return favorites.map((f) => f.station);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addFavorite(userId: string, stationId: string) {
|
||||||
|
return this.prisma.userFavorite.create({
|
||||||
|
data: { userId, stationId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeFavorite(userId: string, stationId: string) {
|
||||||
|
const favorite = await this.prisma.userFavorite.findUnique({
|
||||||
|
where: { userId_stationId: { userId, stationId } },
|
||||||
|
});
|
||||||
|
if (!favorite) throw new NotFoundException('Favorite not found');
|
||||||
|
await this.prisma.userFavorite.delete({
|
||||||
|
where: { id: favorite.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHistory(
|
||||||
|
userId: string,
|
||||||
|
pagination: { limit: number; offset: number },
|
||||||
|
) {
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.playHistory.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: { station: { include: { nowPlaying: true } } },
|
||||||
|
orderBy: { playedAt: 'desc' },
|
||||||
|
skip: pagination.offset,
|
||||||
|
take: pagination.limit,
|
||||||
|
}),
|
||||||
|
this.prisma.playHistory.count({ where: { userId } }),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
items: items.map((h) => h.station),
|
||||||
|
total,
|
||||||
|
limit: pagination.limit,
|
||||||
|
offset: pagination.offset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async addHistory(userId: string, stationId: string) {
|
||||||
|
return this.prisma.playHistory.create({
|
||||||
|
data: { userId, stationId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
29
test/app.e2e-spec.ts
Normal file
29
test/app.e2e-spec.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { App } from 'supertest/types';
|
||||||
|
import { AppModule } from './../src/app.module';
|
||||||
|
|
||||||
|
describe('AppController (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/ (GET)', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/')
|
||||||
|
.expect(200)
|
||||||
|
.expect('Hello World!');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
9
test/jest-e2e.json
Normal file
9
test/jest-e2e.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": ".",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"testRegex": ".e2e-spec.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
tsconfig.build.json
Normal file
4
tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"resolvePackageJsonExports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2023",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictBindCallApply": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user