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