feat: bootstrap NestJS backend with auth, stations, users, health-check, now-playing
This commit is contained in:
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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user