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