version 1.0.0

This commit is contained in:
diyaa
2026-05-24 20:53:42 +02:00
commit 4b8701e55b
6577 changed files with 25019 additions and 0 deletions
+35
View File
@@ -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;
}
+19
View File
@@ -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();
}
}
+11
View File
@@ -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/);
});
});
+65
View File
@@ -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();
}
}
+28
View File
@@ -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');
}
}
+54
View File
@@ -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 {}
+12
View File
@@ -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 {}
+48
View File
@@ -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(),
};
}
}