feat: bootstrap NestJS backend with auth, stations, users, health-check, now-playing

This commit is contained in:
nk
2026-06-02 13:54:00 +03:00
commit 8aadd62e3c
47 changed files with 13234 additions and 0 deletions

12
.dockerignore Normal file
View 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
View 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
View File

@@ -0,0 +1,6 @@
node_modules
dist
.env
*.log
coverage
.DS_Store

4
.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

27
Dockerfile Normal file
View 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
View 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>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

88
package.json Normal file
View 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
View 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
View 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
View 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
View 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')"

View 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
View 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
View 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
View File

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View 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
View 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
View 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
View 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 } });
}
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthCheckService } from './health-check.service';
@Module({
providers: [HealthCheckService],
})
export class HealthCheckModule {}

View 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
View 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();

View 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);
}
}

View 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 {}

View 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 },
});
}
}

View 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 {}

View 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();
}
}

View 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;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateStationDto } from './create-station.dto';
export class UpdateStationDto extends PartialType(CreateStationDto) {}

View 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);
}
}

View 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 {}

View 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;
}
}

View 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;
}

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

25
tsconfig.json Normal file
View 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
}
}