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

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