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

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