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
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
coverage
.env
+7
View File
@@ -0,0 +1,7 @@
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://velody:velody@localhost:5432/velody?schema=public
STORAGE_ROOT=../runtime/storage
PUBLIC_BASE_URL=http://localhost:3000
DEVICE_BOOTSTRAP_SECRET=replace-me
MAX_UPLOAD_SIZE_BYTES=524288000
+28
View File
@@ -0,0 +1,28 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY backend/package.json backend/package-lock.json* ./backend/
WORKDIR /app/backend
RUN npm install
COPY backend ./
RUN npx prisma generate
RUN npm run build
FROM node:20-alpine
WORKDIR /app/backend
COPY --from=builder /app/backend/package.json ./
COPY --from=builder /app/backend/package-lock.json* ./
COPY --from=builder /app/backend/node_modules ./node_modules
COPY --from=builder /app/backend/dist ./dist
COPY --from=builder /app/backend/prisma ./prisma
WORKDIR /app
COPY runtime ./runtime
WORKDIR /app/backend
EXPOSE 3000
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main.js"]
+12
View File
@@ -0,0 +1,12 @@
module.exports = {
moduleFileExtensions: ["js", "json", "ts"],
rootDir: ".",
testRegex: ".*\\.spec\\.ts$",
setupFiles: ["<rootDir>/test/setup-env.ts"],
transform: {
"^.+\\.(t|j)s$": "ts-jest",
},
collectCoverageFrom: ["src/**/*.ts"],
coverageDirectory: "./coverage",
testEnvironment: "node",
};
+599
View File
@@ -0,0 +1,599 @@
{
"openapi": "3.0.0",
"paths": {
"/api/v1/health": {
"get": {
"operationId": "HealthController_getHealth_v1",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HealthResponseDto"
}
}
}
}
},
"tags": [
"health"
]
}
},
"/api/v1/devices/register": {
"post": {
"operationId": "DevicesController_register_v1",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RegisterDeviceRequestDto"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RegisterDeviceResponseDto"
}
}
}
}
},
"tags": [
"devices"
]
}
},
"/api/v1/devices/heartbeat": {
"post": {
"operationId": "DevicesController_heartbeat_v1",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeviceHeartbeatRequestDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeviceHeartbeatResponseDto"
}
}
}
}
},
"tags": [
"devices"
]
}
},
"/api/v1/uploads/prepare": {
"post": {
"operationId": "UploadsController_prepare_v1",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UploadPrepareRequestDto"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UploadPrepareResponseDto"
}
}
}
}
},
"tags": [
"uploads"
]
}
},
"/api/v1/uploads/{uploadId}": {
"get": {
"operationId": "UploadsController_getStatus_v1",
"parameters": [
{
"name": "uploadId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UploadSessionStatusResponseDto"
}
}
}
}
},
"tags": [
"uploads"
]
}
},
"/api/v1/uploads/{uploadId}/finalize": {
"post": {
"operationId": "UploadsController_finalize_v1",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UploadFinalizeResponseDto"
}
}
}
}
},
"summary": "Reserved for the next milestone",
"tags": [
"uploads"
]
}
},
"/api/v1/sync/bootstrap": {
"get": {
"operationId": "SyncController_bootstrap_v1",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SyncBootstrapResponseDto"
}
}
}
}
},
"tags": [
"sync"
]
}
},
"/api/v1/sync/changes": {
"get": {
"operationId": "SyncController_changes_v1",
"parameters": [
{
"name": "after",
"required": false,
"in": "query",
"schema": {
"example": "0",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SyncChangesResponseDto"
}
}
}
}
},
"tags": [
"sync"
]
}
}
},
"info": {
"title": "Velody API",
"description": "Velody Phase 1 foundation API",
"version": "1.0.0",
"contact": {}
},
"tags": [],
"servers": [],
"components": {
"schemas": {
"HealthDependencyDto": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "up"
}
},
"required": [
"status"
]
},
"StorageDependencyDto": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "up"
},
"root": {
"type": "string",
"example": "/app/runtime/storage"
}
},
"required": [
"status",
"root"
]
},
"HealthResponseDto": {
"type": "object",
"properties": {
"service": {
"type": "string",
"example": "velody-backend"
},
"version": {
"type": "string",
"example": "0.1.0"
},
"database": {
"$ref": "#/components/schemas/HealthDependencyDto"
},
"storage": {
"$ref": "#/components/schemas/StorageDependencyDto"
},
"serverTime": {
"type": "string",
"example": "2026-05-24T20:00:00.000Z"
}
},
"required": [
"service",
"version",
"database",
"storage",
"serverTime"
]
},
"RegisterDeviceRequestDto": {
"type": "object",
"properties": {
"platform": {
"type": "string",
"enum": [
"MACOS",
"IPHONE"
],
"example": "MACOS"
},
"deviceName": {
"type": "string",
"example": "Diya MacBook Pro"
},
"appVersion": {
"type": "string",
"example": "0.1.0"
}
},
"required": [
"platform",
"deviceName",
"appVersion"
]
},
"RegisterDeviceResponseDto": {
"type": "object",
"properties": {
"deviceId": {
"type": "string",
"format": "uuid"
},
"bootstrapToken": {
"type": "string"
},
"serverTime": {
"type": "string",
"example": "2026-05-24T20:00:00.000Z"
}
},
"required": [
"deviceId",
"bootstrapToken",
"serverTime"
]
},
"DeviceHeartbeatRequestDto": {
"type": "object",
"properties": {
"deviceId": {
"type": "string",
"format": "uuid"
},
"appVersion": {
"type": "string",
"example": "0.1.0"
}
},
"required": [
"deviceId",
"appVersion"
]
},
"DeviceHeartbeatResponseDto": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"example": true
},
"serverTime": {
"type": "string",
"example": "2026-05-24T20:00:00.000Z"
}
},
"required": [
"ok",
"serverTime"
]
},
"UploadPrepareRequestDto": {
"type": "object",
"properties": {
"deviceId": {
"type": "string",
"format": "uuid"
},
"sha256": {
"type": "string",
"example": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
},
"originalFilename": {
"type": "string",
"example": "track.mp3"
},
"sizeBytes": {
"type": "number",
"example": 10485760
}
},
"required": [
"deviceId",
"sha256",
"originalFilename",
"sizeBytes"
]
},
"UploadPrepareResponseDto": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": [
"exists",
"upload_required"
]
},
"uploadId": {
"type": "string",
"format": "uuid"
},
"nextOffset": {
"type": "number",
"example": 0
}
},
"required": [
"status"
]
},
"UploadSessionStatusResponseDto": {
"type": "object",
"properties": {
"uploadId": {
"type": "string",
"format": "uuid"
},
"status": {
"type": "string",
"enum": [
"PENDING",
"READY_TO_UPLOAD",
"COMPLETED",
"FAILED"
]
},
"receivedBytes": {
"type": "string",
"example": 0
},
"expectedSizeBytes": {
"type": "string",
"example": 10485760
},
"nextOffset": {
"type": "string",
"example": 0
}
},
"required": [
"uploadId",
"status",
"receivedBytes",
"expectedSizeBytes",
"nextOffset"
]
},
"UploadFinalizeResponseDto": {
"type": "object",
"properties": {
"statusCode": {
"type": "number",
"example": 501
},
"message": {
"type": "string",
"example": "Upload finalization is not implemented yet."
}
},
"required": [
"statusCode",
"message"
]
},
"LibraryTrackDto": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"title": {
"type": "string",
"example": "Placeholder Track"
},
"artist": {
"type": "string",
"example": "Velody"
}
}
},
"SyncEventDto": {
"type": "object",
"properties": {
"entityType": {
"type": "string",
"example": "TRACK"
},
"entityId": {
"type": "string",
"format": "uuid"
},
"action": {
"type": "string",
"example": "CREATED"
},
"eventId": {
"type": "string",
"example": "0"
}
},
"required": [
"entityType",
"entityId",
"action",
"eventId"
]
},
"SyncBootstrapResponseDto": {
"type": "object",
"properties": {
"nextCursor": {
"type": "string",
"example": "0"
},
"tracks": {
"type": "array",
"items": {
"$ref": "#/components/schemas/LibraryTrackDto"
}
},
"events": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SyncEventDto"
}
},
"deletedTrackIds": {
"type": "array",
"items": {
"type": "string"
}
},
"serverTime": {
"type": "string",
"example": "2026-05-24T20:00:00.000Z"
}
},
"required": [
"nextCursor",
"tracks",
"events",
"deletedTrackIds",
"serverTime"
]
},
"SyncChangesResponseDto": {
"type": "object",
"properties": {
"nextCursor": {
"type": "string",
"example": "0"
},
"tracks": {
"type": "array",
"items": {
"$ref": "#/components/schemas/LibraryTrackDto"
}
},
"events": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SyncEventDto"
}
},
"deletedTrackIds": {
"type": "array",
"items": {
"type": "string"
}
},
"serverTime": {
"type": "string",
"example": "2026-05-24T20:00:00.000Z"
}
},
"required": [
"nextCursor",
"tracks",
"events",
"deletedTrackIds",
"serverTime"
]
}
}
}
}
+7067
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
{
"name": "velody-backend",
"version": "0.1.0",
"private": true,
"description": "Velody Phase 1 backend foundation",
"license": "UNLICENSED",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"start": "node dist/main.js",
"start:dev": "node --watch --enable-source-maps dist/main.js",
"test": "jest --runInBand",
"test:e2e": "jest --config test/jest-e2e.json --runInBand",
"prisma:generate": "prisma generate",
"prisma:migrate:deploy": "prisma migrate deploy",
"prisma:migrate:dev": "prisma migrate dev",
"openapi:json": "ts-node --project tsconfig.json scripts/generate-openapi.ts"
},
"dependencies": {
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/swagger": "^11.0.0",
"@prisma/client": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.1"
},
"devDependencies": {
"@nestjs/testing": "^11.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.0",
"@types/supertest": "^6.0.2",
"jest": "^30.0.0",
"prisma": "^6.0.0",
"supertest": "^7.0.0",
"ts-jest": "^29.4.0",
"ts-node": "^10.9.2",
"typescript": "^5.8.0"
}
}
@@ -0,0 +1,138 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TYPE "DevicePlatform" AS ENUM ('MACOS', 'IPHONE');
CREATE TYPE "TrackStatus" AS ENUM ('ACTIVE', 'DELETED');
CREATE TYPE "UploadSessionStatus" AS ENUM ('PENDING', 'READY_TO_UPLOAD', 'COMPLETED', 'FAILED');
CREATE TYPE "EntityType" AS ENUM ('TRACK', 'AUDIO_ASSET', 'ARTWORK_ASSET');
CREATE TYPE "EventAction" AS ENUM ('CREATED', 'UPDATED', 'DELETED');
CREATE TABLE "devices" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"platform" "DevicePlatform" NOT NULL,
"device_name" TEXT NOT NULL,
"app_version" TEXT NOT NULL,
"install_token_hash" TEXT NOT NULL,
"last_seen_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "devices_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "tracks" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"primary_audio_asset_id" UUID,
"artwork_asset_id" UUID,
"title" TEXT NOT NULL,
"artist" TEXT NOT NULL,
"album" TEXT,
"album_artist" TEXT,
"genre" TEXT,
"disc_number" INTEGER,
"track_number" INTEGER,
"year" INTEGER,
"duration_ms" INTEGER,
"status" "TrackStatus" NOT NULL DEFAULT 'ACTIVE',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "tracks_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "audio_assets" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"track_id" UUID,
"sha256" TEXT NOT NULL,
"storage_key" TEXT NOT NULL,
"original_filename" TEXT NOT NULL,
"mime_type" TEXT NOT NULL,
"file_extension" TEXT NOT NULL,
"file_size_bytes" BIGINT NOT NULL,
"bit_rate_kbps" INTEGER,
"sample_rate_hz" INTEGER,
"channels" INTEGER,
"duration_ms" INTEGER,
"source_device_id" UUID,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audio_assets_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "artwork_assets" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"sha256" TEXT NOT NULL,
"storage_key" TEXT NOT NULL,
"mime_type" TEXT NOT NULL,
"width" INTEGER,
"height" INTEGER,
"file_size_bytes" BIGINT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "artwork_assets_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "upload_sessions" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"device_id" UUID NOT NULL,
"expected_sha256" TEXT NOT NULL,
"expected_size_bytes" BIGINT NOT NULL,
"received_bytes" BIGINT NOT NULL DEFAULT 0,
"temp_storage_path" TEXT NOT NULL,
"status" "UploadSessionStatus" NOT NULL DEFAULT 'PENDING',
"expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "upload_sessions_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "library_events" (
"id" BIGSERIAL NOT NULL,
"entity_type" "EntityType" NOT NULL,
"entity_id" UUID NOT NULL,
"action" "EventAction" NOT NULL,
"payload_version" INTEGER NOT NULL DEFAULT 1,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "library_events_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "device_sync_cursors" (
"device_id" UUID NOT NULL,
"last_event_id" BIGINT NOT NULL DEFAULT 0,
"last_full_sync_at" TIMESTAMP(3),
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "device_sync_cursors_pkey" PRIMARY KEY ("device_id")
);
CREATE UNIQUE INDEX "tracks_primary_audio_asset_id_key" ON "tracks"("primary_audio_asset_id");
CREATE UNIQUE INDEX "tracks_artwork_asset_id_key" ON "tracks"("artwork_asset_id");
CREATE UNIQUE INDEX "audio_assets_sha256_key" ON "audio_assets"("sha256");
CREATE UNIQUE INDEX "audio_assets_storage_key" ON "audio_assets"("storage_key");
CREATE UNIQUE INDEX "artwork_assets_sha256_key" ON "artwork_assets"("sha256");
CREATE UNIQUE INDEX "artwork_assets_storage_key" ON "artwork_assets"("storage_key");
ALTER TABLE "audio_assets"
ADD CONSTRAINT "audio_assets_track_id_fkey"
FOREIGN KEY ("track_id") REFERENCES "tracks"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "audio_assets"
ADD CONSTRAINT "audio_assets_source_device_id_fkey"
FOREIGN KEY ("source_device_id") REFERENCES "devices"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "tracks"
ADD CONSTRAINT "tracks_primary_audio_asset_id_fkey"
FOREIGN KEY ("primary_audio_asset_id") REFERENCES "audio_assets"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "tracks"
ADD CONSTRAINT "tracks_artwork_asset_id_fkey"
FOREIGN KEY ("artwork_asset_id") REFERENCES "artwork_assets"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "upload_sessions"
ADD CONSTRAINT "upload_sessions_device_id_fkey"
FOREIGN KEY ("device_id") REFERENCES "devices"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "device_sync_cursors"
ADD CONSTRAINT "device_sync_cursors_device_id_fkey"
FOREIGN KEY ("device_id") REFERENCES "devices"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
# Please do not edit this file manually
provider = "postgresql"
+150
View File
@@ -0,0 +1,150 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Device {
id String @id @default(uuid()) @db.Uuid
platform DevicePlatform
deviceName String @map("device_name")
appVersion String @map("app_version")
installTokenHash String @map("install_token_hash")
lastSeenAt DateTime @map("last_seen_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
uploadSessions UploadSession[]
syncCursor DeviceSyncCursor?
audioAssets AudioAsset[]
@@map("devices")
}
model Track {
id String @id @default(uuid()) @db.Uuid
primaryAudioAssetId String? @unique @db.Uuid @map("primary_audio_asset_id")
artworkAssetId String? @unique @db.Uuid @map("artwork_asset_id")
title String
artist String
album String?
albumArtist String? @map("album_artist")
genre String?
discNumber Int? @map("disc_number")
trackNumber Int? @map("track_number")
year Int?
durationMs Int? @map("duration_ms")
status TrackStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
primaryAudioAsset AudioAsset? @relation("PrimaryAudioAsset", fields: [primaryAudioAssetId], references: [id], onDelete: SetNull, onUpdate: Cascade)
artworkAsset ArtworkAsset? @relation("TrackArtwork", fields: [artworkAssetId], references: [id], onDelete: SetNull, onUpdate: Cascade)
audioAssets AudioAsset[] @relation("TrackAudioAssets")
@@map("tracks")
}
model AudioAsset {
id String @id @default(uuid()) @db.Uuid
trackId String? @db.Uuid @map("track_id")
sha256 String @unique
storageKey String @unique @map("storage_key")
originalFilename String @map("original_filename")
mimeType String @map("mime_type")
fileExtension String @map("file_extension")
fileSizeBytes BigInt @map("file_size_bytes")
bitRateKbps Int? @map("bit_rate_kbps")
sampleRateHz Int? @map("sample_rate_hz")
channels Int?
durationMs Int? @map("duration_ms")
sourceDeviceId String? @db.Uuid @map("source_device_id")
createdAt DateTime @default(now()) @map("created_at")
track Track? @relation("TrackAudioAssets", fields: [trackId], references: [id], onDelete: SetNull, onUpdate: Cascade)
primaryForTrack Track? @relation("PrimaryAudioAsset")
sourceDevice Device? @relation(fields: [sourceDeviceId], references: [id], onDelete: SetNull, onUpdate: Cascade)
@@map("audio_assets")
}
model ArtworkAsset {
id String @id @default(uuid()) @db.Uuid
sha256 String @unique
storageKey String @unique @map("storage_key")
mimeType String @map("mime_type")
width Int?
height Int?
fileSizeBytes BigInt @map("file_size_bytes")
createdAt DateTime @default(now()) @map("created_at")
track Track? @relation("TrackArtwork")
@@map("artwork_assets")
}
model UploadSession {
id String @id @default(uuid()) @db.Uuid
deviceId String @db.Uuid @map("device_id")
expectedSha256 String @map("expected_sha256")
expectedSizeBytes BigInt @map("expected_size_bytes")
receivedBytes BigInt @default(0) @map("received_bytes")
tempStoragePath String @map("temp_storage_path")
status UploadSessionStatus @default(PENDING)
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@map("upload_sessions")
}
model LibraryEvent {
id BigInt @id @default(autoincrement())
entityType EntityType @map("entity_type")
entityId String @db.Uuid @map("entity_id")
action EventAction
payloadVersion Int @default(1) @map("payload_version")
createdAt DateTime @default(now()) @map("created_at")
@@map("library_events")
}
model DeviceSyncCursor {
deviceId String @id @db.Uuid @map("device_id")
lastEventId BigInt @default(0) @map("last_event_id")
lastFullSyncAt DateTime? @map("last_full_sync_at")
updatedAt DateTime @updatedAt @map("updated_at")
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@map("device_sync_cursors")
}
enum DevicePlatform {
MACOS
IPHONE
}
enum TrackStatus {
ACTIVE
DELETED
}
enum UploadSessionStatus {
PENDING
READY_TO_UPLOAD
COMPLETED
FAILED
}
enum EntityType {
TRACK
AUDIO_ASSET
ARTWORK_ASSET
}
enum EventAction {
CREATED
UPDATED
DELETED
}
+36
View File
@@ -0,0 +1,36 @@
import 'reflect-metadata';
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
async function generate(): Promise<void> {
process.env.NODE_ENV ??= 'development';
process.env.PORT ??= '3000';
process.env.DATABASE_URL ??=
'postgresql://velody:velody@localhost:5432/velody?schema=public';
process.env.STORAGE_ROOT ??= join(process.cwd(), '..', 'runtime', 'storage');
process.env.PUBLIC_BASE_URL ??= 'http://localhost:3000';
process.env.DEVICE_BOOTSTRAP_SECRET ??= 'openapi-placeholder-secret';
process.env.MAX_UPLOAD_SIZE_BYTES ??= '524288000';
const { createApp } = await import('../src/app.factory');
const app = await createApp();
const document = SwaggerModule.createDocument(
app,
new DocumentBuilder()
.setTitle('Velody API')
.setDescription('Velody Phase 1 foundation API')
.setVersion('1.0.0')
.build(),
);
const outputDir = join(process.cwd(), 'openapi');
await mkdir(outputDir, { recursive: true });
await writeFile(
join(outputDir, 'velody.openapi.json'),
JSON.stringify(document, null, 2),
);
await app.close();
}
void generate();
+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(),
};
}
}
+146
View File
@@ -0,0 +1,146 @@
import { randomUUID } from 'node:crypto';
import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { AppModule } from '../../src/app.module';
import { AppConfigService } from '../../src/modules/config/config.service';
import { DevicesController } from '../../src/modules/devices/devices.controller';
import { HealthController } from '../../src/modules/health/health.controller';
import { SyncController } from '../../src/modules/sync/sync.controller';
import { LocalFilesystemStorageService } from '../../src/modules/storage/storage.service';
import { PrismaService } from '../../src/infrastructure/database/prisma.service';
function createPrismaMock() {
const devices = new Map<string, any>();
return {
$queryRawUnsafe: jest.fn().mockResolvedValue([{ '?column?': 1 }]),
device: {
create: jest.fn().mockImplementation(async ({ data }) => {
const id = randomUUID();
const record = { id, ...data };
devices.set(id, record);
return record;
}),
findUnique: jest.fn().mockImplementation(async ({ where }) => {
return devices.get(where.id) ?? null;
}),
update: jest.fn().mockImplementation(async ({ where, data }) => {
const current = devices.get(where.id);
const updated = { ...current, ...data };
devices.set(where.id, updated);
return updated;
}),
},
audioAsset: {
findUnique: jest.fn().mockResolvedValue(null),
},
uploadSession: {
create: jest.fn().mockImplementation(async ({ data }) => {
return {
id: randomUUID(),
...data,
};
}),
findUnique: jest.fn().mockResolvedValue(null),
},
libraryEvent: {
findFirst: jest.fn().mockResolvedValue(null),
},
};
}
describe('Velody API wiring (e2e)', () => {
let app: INestApplication;
let healthController: HealthController;
let devicesController: DevicesController;
let syncController: SyncController;
beforeEach(async () => {
const prismaMock = createPrismaMock();
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(AppConfigService)
.useValue({
appVersion: '0.1.0',
maxUploadSizeBytes: 1024 * 1024 * 1024,
storageRoot: '/tmp/velody-storage',
})
.overrideProvider(LocalFilesystemStorageService)
.useValue({
root: '/tmp/velody-storage',
checkReadiness: jest.fn().mockResolvedValue({
root: '/tmp/velody-storage',
writable: true,
}),
})
.overrideProvider(PrismaService)
.useValue(prismaMock)
.compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix('api');
app.enableVersioning({ type: VersioningType.URI });
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.init();
healthController = moduleRef.get(HealthController);
devicesController = moduleRef.get(DevicesController);
syncController = moduleRef.get(SyncController);
});
afterEach(async () => {
if (app) {
await app.close();
}
});
it('returns health information', async () => {
const response = await healthController.getHealth();
expect(response.database.status).toBe('up');
expect(response.storage.status).toBe('up');
expect(response.version).toBe('0.1.0');
});
it('registers a device', async () => {
const response = await devicesController.register({
platform: 'MACOS',
deviceName: 'Diya MacBook Pro',
appVersion: '0.1.0',
});
expect(response.deviceId).toBeDefined();
expect(response.bootstrapToken).toBeDefined();
});
it('accepts device heartbeat', async () => {
const registerResponse = await devicesController.register({
platform: 'IPHONE',
deviceName: 'Diya iPhone',
appVersion: '0.1.0',
});
const response = await devicesController.heartbeat({
deviceId: registerResponse.deviceId,
appVersion: '0.1.1',
});
expect(response.ok).toBe(true);
});
it('returns empty sync bootstrap and changes payloads', async () => {
const bootstrapResponse = await syncController.bootstrap();
const changesResponse = await syncController.changes({ after: '0' });
expect(bootstrapResponse.tracks).toEqual([]);
expect(changesResponse.events).toEqual([]);
expect(changesResponse.nextCursor).toBe('0');
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "..",
"testRegex": ".*\\.e2e-spec\\.ts$",
"setupFiles": ["<rootDir>/test/setup-env.ts"],
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"testEnvironment": "node"
}
+15
View File
@@ -0,0 +1,15 @@
import 'reflect-metadata';
process.env.NODE_ENV = process.env.NODE_ENV ?? 'test';
process.env.PORT = process.env.PORT ?? '3001';
process.env.DATABASE_URL =
process.env.DATABASE_URL ??
'postgresql://velody:velody@localhost:5432/velody?schema=public';
process.env.STORAGE_ROOT =
process.env.STORAGE_ROOT ?? '/tmp/velody-storage';
process.env.PUBLIC_BASE_URL =
process.env.PUBLIC_BASE_URL ?? 'http://localhost:3000';
process.env.DEVICE_BOOTSTRAP_SECRET =
process.env.DEVICE_BOOTSTRAP_SECRET ?? 'test-secret';
process.env.MAX_UPLOAD_SIZE_BYTES =
process.env.MAX_UPLOAD_SIZE_BYTES ?? '1073741824';
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": false,
"sourceMap": true
},
"exclude": [
"test",
"scripts",
"**/*.spec.ts"
]
}
+32
View File
@@ -0,0 +1,32 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2022",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strict": true,
"moduleResolution": "node",
"esModuleInterop": true,
"types": [
"node",
"jest"
]
},
"include": [
"src/**/*.ts",
"test/**/*.ts",
"scripts/**/*.ts"
],
"exclude": [
"dist",
"node_modules"
]
}