version 1.0.0
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.env
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+7067
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"exclude": [
|
||||
"test",
|
||||
"scripts",
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user