version 1.0.0
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { ValidationPipe, VersioningType } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
export async function createApp() {
|
||||
const app = await NestFactory.create(AppModule, { bufferLogs: true });
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
app.enableVersioning({
|
||||
type: VersioningType.URI,
|
||||
});
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const document = SwaggerModule.createDocument(
|
||||
app,
|
||||
new DocumentBuilder()
|
||||
.setTitle('Velody API')
|
||||
.setDescription('Velody Phase 1 foundation API')
|
||||
.setVersion('1.0.0')
|
||||
.build(),
|
||||
);
|
||||
|
||||
SwaggerModule.setup('api/docs', app, document, {
|
||||
jsonDocumentUrl: 'api/docs-json',
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AppConfigModule } from './modules/config/config.module';
|
||||
import { DevicesModule } from './modules/devices/devices.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { LibraryModule } from './modules/library/library.module';
|
||||
import { SyncModule } from './modules/sync/sync.module';
|
||||
import { UploadsModule } from './modules/uploads/uploads.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AppConfigModule,
|
||||
HealthModule,
|
||||
DevicesModule,
|
||||
UploadsModule,
|
||||
LibraryModule,
|
||||
SyncModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit {
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.$connect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'reflect-metadata';
|
||||
import { createApp } from './app.factory';
|
||||
import { AppConfigService } from './modules/config/config.service';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const app = await createApp();
|
||||
const port = app.get(AppConfigService).port;
|
||||
await app.listen(port);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { join } from 'node:path';
|
||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||
import { AppConfigService } from './config.service';
|
||||
import { validateEnvironment } from './environment';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: [join(process.cwd(), '.env'), join(process.cwd(), '..', '.env')],
|
||||
validate: validateEnvironment,
|
||||
}),
|
||||
PrismaModule,
|
||||
],
|
||||
providers: [AppConfigService],
|
||||
exports: [AppConfigService],
|
||||
})
|
||||
export class AppConfigModule {}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class AppConfigService {
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
get nodeEnv(): string {
|
||||
return this.required('NODE_ENV');
|
||||
}
|
||||
|
||||
get port(): number {
|
||||
return Number(this.required('PORT'));
|
||||
}
|
||||
|
||||
get databaseUrl(): string {
|
||||
return this.required('DATABASE_URL');
|
||||
}
|
||||
|
||||
get storageRoot(): string {
|
||||
return this.required('STORAGE_ROOT');
|
||||
}
|
||||
|
||||
get publicBaseUrl(): string {
|
||||
return this.required('PUBLIC_BASE_URL');
|
||||
}
|
||||
|
||||
get deviceBootstrapSecret(): string {
|
||||
return this.required('DEVICE_BOOTSTRAP_SECRET');
|
||||
}
|
||||
|
||||
get maxUploadSizeBytes(): number {
|
||||
return Number(this.required('MAX_UPLOAD_SIZE_BYTES'));
|
||||
}
|
||||
|
||||
get appVersion(): string {
|
||||
return process.env.npm_package_version ?? '0.1.0';
|
||||
}
|
||||
|
||||
private required(key: string): string {
|
||||
const value = this.configService.get<string>(key);
|
||||
if (!value) {
|
||||
throw new Error(`Missing required configuration value for ${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { validateEnvironment } from './environment';
|
||||
|
||||
describe('validateEnvironment', () => {
|
||||
it('accepts a valid environment', () => {
|
||||
const result = validateEnvironment({
|
||||
NODE_ENV: 'test',
|
||||
PORT: '3000',
|
||||
DATABASE_URL: 'postgresql://velody:velody@localhost:5432/velody?schema=public',
|
||||
STORAGE_ROOT: '/tmp/velody',
|
||||
PUBLIC_BASE_URL: 'http://localhost:3000',
|
||||
DEVICE_BOOTSTRAP_SECRET: 'secret',
|
||||
MAX_UPLOAD_SIZE_BYTES: '1024',
|
||||
});
|
||||
|
||||
expect(result.PORT).toBe(3000);
|
||||
expect(result.MAX_UPLOAD_SIZE_BYTES).toBe(1024);
|
||||
});
|
||||
|
||||
it('throws for missing values', () => {
|
||||
expect(() =>
|
||||
validateEnvironment({
|
||||
NODE_ENV: 'test',
|
||||
}),
|
||||
).toThrow(/Invalid environment configuration/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsString,
|
||||
IsUrl,
|
||||
Max,
|
||||
Min,
|
||||
validateSync,
|
||||
} from 'class-validator';
|
||||
|
||||
class EnvironmentVariables {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
NODE_ENV!: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(65535)
|
||||
PORT!: number;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
DATABASE_URL!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
STORAGE_ROOT!: string;
|
||||
|
||||
@IsUrl({
|
||||
require_tld: false,
|
||||
require_protocol: true,
|
||||
})
|
||||
PUBLIC_BASE_URL!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
DEVICE_BOOTSTRAP_SECRET!: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
MAX_UPLOAD_SIZE_BYTES!: number;
|
||||
}
|
||||
|
||||
export function validateEnvironment(config: Record<string, unknown>) {
|
||||
const validated = plainToInstance(EnvironmentVariables, config, {
|
||||
enableImplicitConversion: true,
|
||||
});
|
||||
|
||||
const errors = validateSync(validated, {
|
||||
skipMissingProperties: false,
|
||||
});
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(
|
||||
`Invalid environment configuration: ${errors
|
||||
.map((error) => Object.values(error.constraints ?? {}).join(', '))
|
||||
.join('; ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return validated;
|
||||
}
|
||||
|
||||
export type ValidEnvironment = ReturnType<typeof validateEnvironment>;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { ApiCreatedResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
DeviceHeartbeatRequestDto,
|
||||
DeviceHeartbeatResponseDto,
|
||||
RegisterDeviceRequestDto,
|
||||
RegisterDeviceResponseDto,
|
||||
} from './devices.dto';
|
||||
import { DevicesService } from './devices.service';
|
||||
|
||||
@ApiTags('devices')
|
||||
@Controller({
|
||||
path: 'devices',
|
||||
version: '1',
|
||||
})
|
||||
export class DevicesController {
|
||||
constructor(private readonly devicesService: DevicesService) {}
|
||||
|
||||
@Post('register')
|
||||
@ApiCreatedResponse({ type: RegisterDeviceResponseDto })
|
||||
async register(
|
||||
@Body() body: RegisterDeviceRequestDto,
|
||||
): Promise<RegisterDeviceResponseDto> {
|
||||
return this.devicesService.register(body);
|
||||
}
|
||||
|
||||
@Post('heartbeat')
|
||||
@ApiOkResponse({ type: DeviceHeartbeatResponseDto })
|
||||
async heartbeat(
|
||||
@Body() body: DeviceHeartbeatRequestDto,
|
||||
): Promise<DeviceHeartbeatResponseDto> {
|
||||
return this.devicesService.heartbeat(body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { DevicePlatform } from '@prisma/client';
|
||||
import { IsEnum, IsString, IsUUID, MinLength } from 'class-validator';
|
||||
|
||||
export class RegisterDeviceRequestDto {
|
||||
@ApiProperty({ enum: DevicePlatform, example: DevicePlatform.MACOS })
|
||||
@IsEnum(DevicePlatform)
|
||||
platform!: DevicePlatform;
|
||||
|
||||
@ApiProperty({ example: 'Diya MacBook Pro' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
deviceName!: string;
|
||||
|
||||
@ApiProperty({ example: '0.1.0' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
appVersion!: string;
|
||||
}
|
||||
|
||||
export class RegisterDeviceResponseDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
deviceId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
bootstrapToken!: string;
|
||||
|
||||
@ApiProperty({ example: '2026-05-24T20:00:00.000Z' })
|
||||
serverTime!: string;
|
||||
}
|
||||
|
||||
export class DeviceHeartbeatRequestDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
deviceId!: string;
|
||||
|
||||
@ApiProperty({ example: '0.1.0' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
appVersion!: string;
|
||||
}
|
||||
|
||||
export class DeviceHeartbeatResponseDto {
|
||||
@ApiProperty({ example: true })
|
||||
ok!: boolean;
|
||||
|
||||
@ApiProperty({ example: '2026-05-24T20:00:00.000Z' })
|
||||
serverTime!: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||
import { DevicesController } from './devices.controller';
|
||||
import { DevicesService } from './devices.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [DevicesController],
|
||||
providers: [DevicesService],
|
||||
exports: [DevicesService],
|
||||
})
|
||||
export class DevicesModule {}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import {
|
||||
DeviceHeartbeatRequestDto,
|
||||
DeviceHeartbeatResponseDto,
|
||||
RegisterDeviceRequestDto,
|
||||
RegisterDeviceResponseDto,
|
||||
} from './devices.dto';
|
||||
|
||||
@Injectable()
|
||||
export class DevicesService {
|
||||
constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
async register(
|
||||
body: RegisterDeviceRequestDto,
|
||||
): Promise<RegisterDeviceResponseDto> {
|
||||
const bootstrapToken = randomBytes(24).toString('hex');
|
||||
const installTokenHash = createHash('sha256')
|
||||
.update(bootstrapToken)
|
||||
.digest('hex');
|
||||
|
||||
const device = await this.prismaService.device.create({
|
||||
data: {
|
||||
platform: body.platform,
|
||||
deviceName: body.deviceName,
|
||||
appVersion: body.appVersion,
|
||||
installTokenHash,
|
||||
lastSeenAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
deviceId: device.id,
|
||||
bootstrapToken,
|
||||
serverTime: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async heartbeat(
|
||||
body: DeviceHeartbeatRequestDto,
|
||||
): Promise<DeviceHeartbeatResponseDto> {
|
||||
const existing = await this.prismaService.device.findUnique({
|
||||
where: { id: body.deviceId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Device not found');
|
||||
}
|
||||
|
||||
await this.prismaService.device.update({
|
||||
where: { id: body.deviceId },
|
||||
data: {
|
||||
appVersion: body.appVersion,
|
||||
lastSeenAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
serverTime: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { HealthResponseDto } from './health.dto';
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@ApiTags('health')
|
||||
@Controller({
|
||||
path: 'health',
|
||||
version: '1',
|
||||
})
|
||||
export class HealthController {
|
||||
constructor(private readonly healthService: HealthService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOkResponse({ type: HealthResponseDto })
|
||||
async getHealth(): Promise<HealthResponseDto> {
|
||||
return this.healthService.getHealth();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
class HealthDependencyDto {
|
||||
@ApiProperty({ example: 'up' })
|
||||
status!: 'up' | 'down';
|
||||
}
|
||||
|
||||
class StorageDependencyDto extends HealthDependencyDto {
|
||||
@ApiProperty({ example: '/app/runtime/storage' })
|
||||
root!: string;
|
||||
}
|
||||
|
||||
export class HealthResponseDto {
|
||||
@ApiProperty({ example: 'velody-backend' })
|
||||
service!: string;
|
||||
|
||||
@ApiProperty({ example: '0.1.0' })
|
||||
version!: string;
|
||||
|
||||
@ApiProperty({ type: HealthDependencyDto })
|
||||
database!: HealthDependencyDto;
|
||||
|
||||
@ApiProperty({ type: StorageDependencyDto })
|
||||
storage!: StorageDependencyDto;
|
||||
|
||||
@ApiProperty({ example: '2026-05-24T20:00:00.000Z' })
|
||||
serverTime!: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { HealthController } from './health.controller';
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, StorageModule],
|
||||
controllers: [HealthController],
|
||||
providers: [HealthService],
|
||||
})
|
||||
export class HealthModule {}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AppConfigService } from '../config/config.service';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||
import { HealthResponseDto } from './health.dto';
|
||||
|
||||
@Injectable()
|
||||
export class HealthService {
|
||||
constructor(
|
||||
private readonly configService: AppConfigService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly storageService: LocalFilesystemStorageService,
|
||||
) {}
|
||||
|
||||
async getHealth(): Promise<HealthResponseDto> {
|
||||
let databaseStatus: 'up' | 'down' = 'up';
|
||||
let storageStatus: 'up' | 'down' = 'up';
|
||||
|
||||
try {
|
||||
await this.prismaService.$queryRawUnsafe('SELECT 1');
|
||||
} catch {
|
||||
databaseStatus = 'down';
|
||||
}
|
||||
|
||||
try {
|
||||
await this.storageService.checkReadiness();
|
||||
} catch {
|
||||
storageStatus = 'down';
|
||||
}
|
||||
|
||||
return {
|
||||
service: 'velody-backend',
|
||||
version: this.configService.appVersion,
|
||||
database: {
|
||||
status: databaseStatus,
|
||||
},
|
||||
storage: {
|
||||
status: storageStatus,
|
||||
root: this.storageService.root,
|
||||
},
|
||||
serverTime: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LibraryService } from './library.service';
|
||||
|
||||
@Module({
|
||||
providers: [LibraryService],
|
||||
exports: [LibraryService],
|
||||
})
|
||||
export class LibraryModule {}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { LibraryTrackDto } from '../sync/sync.dto';
|
||||
|
||||
@Injectable()
|
||||
export class LibraryService {
|
||||
async getBootstrapTracks(): Promise<LibraryTrackDto[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AppConfigModule } from '../config/config.module';
|
||||
import { LocalFilesystemStorageService } from './storage.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [AppConfigModule],
|
||||
providers: [LocalFilesystemStorageService],
|
||||
exports: [LocalFilesystemStorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { mkdir, access } from 'node:fs/promises';
|
||||
import { constants } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { AppConfigService } from '../config/config.service';
|
||||
|
||||
export interface StorageStatus {
|
||||
root: string;
|
||||
writable: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LocalFilesystemStorageService {
|
||||
constructor(private readonly configService: AppConfigService) {}
|
||||
|
||||
get root(): string {
|
||||
return this.configService.storageRoot;
|
||||
}
|
||||
|
||||
async checkReadiness(): Promise<StorageStatus> {
|
||||
const paths = [
|
||||
this.root,
|
||||
join(this.root, 'incoming'),
|
||||
join(this.root, 'quarantine'),
|
||||
join(this.root, 'library', 'audio'),
|
||||
join(this.root, 'library', 'artwork'),
|
||||
join(this.root, 'temp'),
|
||||
];
|
||||
|
||||
for (const path of paths) {
|
||||
await mkdir(path, { recursive: true });
|
||||
await access(path, constants.R_OK | constants.W_OK);
|
||||
}
|
||||
|
||||
return {
|
||||
root: this.root,
|
||||
writable: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
SyncBootstrapResponseDto,
|
||||
SyncChangesQueryDto,
|
||||
SyncChangesResponseDto,
|
||||
} from './sync.dto';
|
||||
import { SyncService } from './sync.service';
|
||||
|
||||
@ApiTags('sync')
|
||||
@Controller({
|
||||
path: 'sync',
|
||||
version: '1',
|
||||
})
|
||||
export class SyncController {
|
||||
constructor(private readonly syncService: SyncService) {}
|
||||
|
||||
@Get('bootstrap')
|
||||
@ApiOkResponse({ type: SyncBootstrapResponseDto })
|
||||
async bootstrap(): Promise<SyncBootstrapResponseDto> {
|
||||
return this.syncService.bootstrap();
|
||||
}
|
||||
|
||||
@Get('changes')
|
||||
@ApiOkResponse({ type: SyncChangesResponseDto })
|
||||
async changes(
|
||||
@Query() query: SyncChangesQueryDto,
|
||||
): Promise<SyncChangesResponseDto> {
|
||||
return this.syncService.changes(query.after ?? '0');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, Matches } from 'class-validator';
|
||||
|
||||
export class LibraryTrackDto {
|
||||
@ApiProperty({ format: 'uuid', required: false })
|
||||
id?: string;
|
||||
|
||||
@ApiProperty({ example: 'Placeholder Track', required: false })
|
||||
title?: string;
|
||||
|
||||
@ApiProperty({ example: 'Velody', required: false })
|
||||
artist?: string;
|
||||
}
|
||||
|
||||
export class SyncEventDto {
|
||||
@ApiProperty({ example: 'TRACK' })
|
||||
entityType!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
entityId!: string;
|
||||
|
||||
@ApiProperty({ example: 'CREATED' })
|
||||
action!: string;
|
||||
|
||||
@ApiProperty({ example: '0' })
|
||||
eventId!: string;
|
||||
}
|
||||
|
||||
export class SyncBootstrapResponseDto {
|
||||
@ApiProperty({ example: '0' })
|
||||
nextCursor!: string;
|
||||
|
||||
@ApiProperty({ type: [LibraryTrackDto] })
|
||||
tracks!: LibraryTrackDto[];
|
||||
|
||||
@ApiProperty({ type: [SyncEventDto] })
|
||||
events!: SyncEventDto[];
|
||||
|
||||
@ApiProperty({ type: [String] })
|
||||
deletedTrackIds!: string[];
|
||||
|
||||
@ApiProperty({ example: '2026-05-24T20:00:00.000Z' })
|
||||
serverTime!: string;
|
||||
}
|
||||
|
||||
export class SyncChangesQueryDto {
|
||||
@ApiProperty({ required: false, example: '0' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^\d+$/)
|
||||
after?: string;
|
||||
}
|
||||
|
||||
export class SyncChangesResponseDto extends SyncBootstrapResponseDto {}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||
import { LibraryModule } from '../library/library.module';
|
||||
import { SyncController } from './sync.controller';
|
||||
import { SyncService } from './sync.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, LibraryModule],
|
||||
controllers: [SyncController],
|
||||
providers: [SyncService],
|
||||
})
|
||||
export class SyncModule {}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { LibraryService } from '../library/library.service';
|
||||
import { SyncBootstrapResponseDto, SyncChangesResponseDto } from './sync.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SyncService {
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly libraryService: LibraryService,
|
||||
) {}
|
||||
|
||||
async bootstrap(): Promise<SyncBootstrapResponseDto> {
|
||||
const latestCursor = await this.getLatestCursor();
|
||||
|
||||
return {
|
||||
nextCursor: latestCursor,
|
||||
tracks: await this.libraryService.getBootstrapTracks(),
|
||||
events: [],
|
||||
deletedTrackIds: [],
|
||||
serverTime: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async changes(after: string): Promise<SyncChangesResponseDto> {
|
||||
const latestCursor = await this.getLatestCursor();
|
||||
const normalizedCursor =
|
||||
BigInt(latestCursor) > BigInt(after) ? latestCursor : after;
|
||||
|
||||
return {
|
||||
nextCursor: normalizedCursor,
|
||||
tracks: [],
|
||||
events: [],
|
||||
deletedTrackIds: [],
|
||||
serverTime: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private async getLatestCursor(): Promise<string> {
|
||||
const latest = await this.prismaService.libraryEvent.findFirst({
|
||||
orderBy: {
|
||||
id: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return latest?.id.toString() ?? '0';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
NotImplementedException,
|
||||
Param,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiCreatedResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import {
|
||||
UploadFinalizeResponseDto,
|
||||
UploadPrepareRequestDto,
|
||||
UploadPrepareResponseDto,
|
||||
UploadSessionStatusResponseDto,
|
||||
} from './uploads.dto';
|
||||
import { UploadsService } from './uploads.service';
|
||||
|
||||
@ApiTags('uploads')
|
||||
@Controller({
|
||||
path: 'uploads',
|
||||
version: '1',
|
||||
})
|
||||
export class UploadsController {
|
||||
constructor(private readonly uploadsService: UploadsService) {}
|
||||
|
||||
@Post('prepare')
|
||||
@ApiCreatedResponse({ type: UploadPrepareResponseDto })
|
||||
async prepare(
|
||||
@Body() body: UploadPrepareRequestDto,
|
||||
): Promise<UploadPrepareResponseDto> {
|
||||
return this.uploadsService.prepare(body);
|
||||
}
|
||||
|
||||
@Get(':uploadId')
|
||||
@ApiOkResponse({ type: UploadSessionStatusResponseDto })
|
||||
async getStatus(
|
||||
@Param('uploadId') uploadId: string,
|
||||
): Promise<UploadSessionStatusResponseDto> {
|
||||
return this.uploadsService.getStatus(uploadId);
|
||||
}
|
||||
|
||||
@Post(':uploadId/finalize')
|
||||
@ApiOperation({ summary: 'Reserved for the next milestone' })
|
||||
@ApiOkResponse({ type: UploadFinalizeResponseDto })
|
||||
async finalize(): Promise<UploadFinalizeResponseDto> {
|
||||
throw new NotImplementedException('Upload finalization is not implemented yet.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { UploadSessionStatus } from '@prisma/client';
|
||||
import { IsInt, IsString, IsUUID, Matches, Max, Min } from 'class-validator';
|
||||
|
||||
export class UploadPrepareRequestDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
deviceId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
example:
|
||||
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
})
|
||||
@Matches(/^[a-f0-9]{64}$/)
|
||||
sha256!: string;
|
||||
|
||||
@ApiProperty({ example: 'track.mp3' })
|
||||
@IsString()
|
||||
originalFilename!: string;
|
||||
|
||||
@ApiProperty({ example: 10485760 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(Number.MAX_SAFE_INTEGER)
|
||||
sizeBytes!: number;
|
||||
}
|
||||
|
||||
export class UploadPrepareResponseDto {
|
||||
@ApiProperty({ enum: ['exists', 'upload_required'] })
|
||||
status!: 'exists' | 'upload_required';
|
||||
|
||||
@ApiProperty({ required: false, format: 'uuid' })
|
||||
uploadId?: string;
|
||||
|
||||
@ApiProperty({ required: false, example: 0 })
|
||||
nextOffset?: number;
|
||||
}
|
||||
|
||||
export class UploadSessionStatusResponseDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
uploadId!: string;
|
||||
|
||||
@ApiProperty({ enum: UploadSessionStatus })
|
||||
status!: UploadSessionStatus;
|
||||
|
||||
@ApiProperty({ example: 0 })
|
||||
receivedBytes!: string;
|
||||
|
||||
@ApiProperty({ example: 10485760 })
|
||||
expectedSizeBytes!: string;
|
||||
|
||||
@ApiProperty({ example: 0 })
|
||||
nextOffset!: string;
|
||||
}
|
||||
|
||||
export class UploadFinalizeResponseDto {
|
||||
@ApiProperty({ example: 501 })
|
||||
statusCode!: number;
|
||||
|
||||
@ApiProperty({ example: 'Upload finalization is not implemented yet.' })
|
||||
message!: string;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||
import { UploadsController } from './uploads.controller';
|
||||
import { UploadsService } from './uploads.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [UploadsController],
|
||||
providers: [UploadsService],
|
||||
})
|
||||
export class UploadsModule {}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnprocessableEntityException,
|
||||
} from '@nestjs/common';
|
||||
import { join } from 'node:path';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { AppConfigService } from '../config/config.service';
|
||||
import { UploadPrepareRequestDto, UploadPrepareResponseDto, UploadSessionStatusResponseDto } from './uploads.dto';
|
||||
import { UploadSessionStatus } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class UploadsService {
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly configService: AppConfigService,
|
||||
) {}
|
||||
|
||||
async prepare(
|
||||
body: UploadPrepareRequestDto,
|
||||
): Promise<UploadPrepareResponseDto> {
|
||||
if (body.sizeBytes > this.configService.maxUploadSizeBytes) {
|
||||
throw new UnprocessableEntityException('Upload exceeds the configured maximum size.');
|
||||
}
|
||||
|
||||
const device = await this.prismaService.device.findUnique({
|
||||
where: { id: body.deviceId },
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
throw new NotFoundException('Device not found');
|
||||
}
|
||||
|
||||
const existingAsset = await this.prismaService.audioAsset.findUnique({
|
||||
where: { sha256: body.sha256 },
|
||||
});
|
||||
|
||||
if (existingAsset) {
|
||||
return {
|
||||
status: 'exists',
|
||||
};
|
||||
}
|
||||
|
||||
const uploadSession = await this.prismaService.uploadSession.create({
|
||||
data: {
|
||||
deviceId: body.deviceId,
|
||||
expectedSha256: body.sha256,
|
||||
expectedSizeBytes: BigInt(body.sizeBytes),
|
||||
receivedBytes: BigInt(0),
|
||||
tempStoragePath: join('incoming', `${body.sha256}.part`),
|
||||
status: UploadSessionStatus.READY_TO_UPLOAD,
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'upload_required',
|
||||
uploadId: uploadSession.id,
|
||||
nextOffset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async getStatus(uploadId: string): Promise<UploadSessionStatusResponseDto> {
|
||||
const uploadSession = await this.prismaService.uploadSession.findUnique({
|
||||
where: { id: uploadId },
|
||||
});
|
||||
|
||||
if (!uploadSession) {
|
||||
throw new NotFoundException('Upload session not found');
|
||||
}
|
||||
|
||||
return {
|
||||
uploadId: uploadSession.id,
|
||||
status: uploadSession.status,
|
||||
receivedBytes: uploadSession.receivedBytes.toString(),
|
||||
expectedSizeBytes: uploadSession.expectedSizeBytes.toString(),
|
||||
nextOffset: uploadSession.receivedBytes.toString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user