diff --git a/backend/prisma/migrations/20260625100000_milestone114_ownership_transfer_foundation/migration.sql b/backend/prisma/migrations/20260625100000_milestone114_ownership_transfer_foundation/migration.sql new file mode 100644 index 0000000..32f2a45 --- /dev/null +++ b/backend/prisma/migrations/20260625100000_milestone114_ownership_transfer_foundation/migration.sql @@ -0,0 +1,13 @@ +ALTER TABLE "ownership_transfers" +ADD COLUMN "requested_by_user_id" UUID, +ADD COLUMN "mode" TEXT NOT NULL DEFAULT 'COPY', +ADD COLUMN "summary" JSONB; + +CREATE INDEX "ownership_transfers_requested_by_user_id_idx" +ON "ownership_transfers"("requested_by_user_id"); + +ALTER TABLE "ownership_transfers" +ADD CONSTRAINT "ownership_transfers_requested_by_user_id_fkey" +FOREIGN KEY ("requested_by_user_id") REFERENCES "users"("id") +ON DELETE SET NULL +ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 64fe45a..e3c4880 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -32,6 +32,7 @@ model User { deviceLinkHistory DeviceLinkHistory[] outgoingTransfers OwnershipTransfer[] @relation("OwnershipTransferFromUser") incomingTransfers OwnershipTransfer[] @relation("OwnershipTransferToUser") + initiatedTransfers OwnershipTransfer[] @relation("OwnershipTransferRequestedByUser") @@map("users") } @@ -262,17 +263,22 @@ model OwnershipTransfer { id String @id @default(uuid()) @db.Uuid fromUserId String @map("from_user_id") @db.Uuid toUserId String? @map("to_user_id") @db.Uuid + requestedByUserId String? @map("requested_by_user_id") @db.Uuid requestedByDeviceId String? @map("requested_by_device_id") @db.Uuid + mode String @default("COPY") status String + summary Json? @map("summary") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") completedAt DateTime? @map("completed_at") fromUser User @relation("OwnershipTransferFromUser", fields: [fromUserId], references: [id], onDelete: Cascade, onUpdate: Cascade) toUser User? @relation("OwnershipTransferToUser", fields: [toUserId], references: [id], onDelete: SetNull, onUpdate: Cascade) + requestedByUser User? @relation("OwnershipTransferRequestedByUser", fields: [requestedByUserId], references: [id], onDelete: SetNull, onUpdate: Cascade) requestedByDevice Device? @relation("OwnershipTransferRequestedByDevice", fields: [requestedByDeviceId], references: [id], onDelete: SetNull, onUpdate: Cascade) @@index([fromUserId]) @@index([toUserId]) + @@index([requestedByUserId]) @@map("ownership_transfers") } diff --git a/backend/src/modules/users/ownership-transfer-migration.spec.ts b/backend/src/modules/users/ownership-transfer-migration.spec.ts new file mode 100644 index 0000000..5be6fed --- /dev/null +++ b/backend/src/modules/users/ownership-transfer-migration.spec.ts @@ -0,0 +1,21 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +describe('ownership transfer foundation migration', () => { + it('adds ownership transfer audit columns for mode, initiator, and summary', async () => { + const migrationSql = await readFile( + join( + process.cwd(), + 'prisma/migrations/20260625100000_milestone114_ownership_transfer_foundation/migration.sql', + ), + 'utf8', + ); + + expect(migrationSql).toContain(`ADD COLUMN "requested_by_user_id" UUID`); + expect(migrationSql).toContain(`ADD COLUMN "mode" TEXT NOT NULL DEFAULT 'COPY'`); + expect(migrationSql).toContain(`ADD COLUMN "summary" JSONB`); + expect(migrationSql).toContain( + `CREATE INDEX "ownership_transfers_requested_by_user_id_idx"`, + ); + }); +}); diff --git a/backend/src/modules/users/ownership-transfer.service.spec.ts b/backend/src/modules/users/ownership-transfer.service.spec.ts new file mode 100644 index 0000000..6f30f2f --- /dev/null +++ b/backend/src/modules/users/ownership-transfer.service.spec.ts @@ -0,0 +1,619 @@ +import { randomUUID } from 'node:crypto'; +import { + BadRequestException, + NotFoundException, +} from '@nestjs/common'; +import { + EntityType, + EventAction, + TrackStatus, + UserAccountKind, + UserAccountStatus, +} from '@prisma/client'; +import { OwnershipTransferService } from './ownership-transfer.service'; + +describe('OwnershipTransferService', () => { + const sourceUserId = randomUUID(); + const targetUserId = randomUUID(); + const initiatedByUserId = randomUUID(); + const initiatedByDeviceId = randomUUID(); + + const buildService = (options?: { + sourceStatus?: UserAccountStatus; + targetStatus?: UserAccountStatus; + sourceKind?: UserAccountKind; + sourceTrackWithExistingTargetAsset?: boolean; + sourceTrackWithExistingTargetArtwork?: boolean; + }) => { + const users = new Map([ + [ + sourceUserId, + { + id: sourceUserId, + accountKind: options?.sourceKind ?? UserAccountKind.LEGACY_DEFAULT, + accountStatus: options?.sourceStatus ?? UserAccountStatus.ACTIVE, + libraryCursor: 0n, + }, + ], + [ + targetUserId, + { + id: targetUserId, + accountKind: UserAccountKind.REGISTERED, + accountStatus: options?.targetStatus ?? UserAccountStatus.ACTIVE, + libraryCursor: 0n, + }, + ], + [ + initiatedByUserId, + { + id: initiatedByUserId, + accountKind: UserAccountKind.REGISTERED, + accountStatus: UserAccountStatus.ACTIVE, + libraryCursor: 0n, + }, + ], + ]); + + const devices = new Map([ + [initiatedByDeviceId, { id: initiatedByDeviceId }], + ]); + + const sourceArtworkId = randomUUID(); + const targetArtworkId = randomUUID(); + const sourceTrackId = randomUUID(); + const targetExistingAudioAssetId = randomUUID(); + const sourceAudioAssetId = randomUUID(); + + const artworkAssets = new Map([ + [ + sourceArtworkId, + { + id: sourceArtworkId, + userId: sourceUserId, + sha256: 'artwork-sha', + storageKey: `users/${sourceUserId}/artwork/artwork-sha.jpg`, + mimeType: 'image/jpeg', + width: 512, + height: 512, + fileSizeBytes: 2048n, + }, + ], + ]); + + if (options?.sourceTrackWithExistingTargetArtwork) { + artworkAssets.set(targetArtworkId, { + id: targetArtworkId, + userId: targetUserId, + sha256: 'artwork-sha', + storageKey: `users/${targetUserId}/artwork/artwork-sha.jpg`, + mimeType: 'image/jpeg', + width: 512, + height: 512, + fileSizeBytes: 2048n, + }); + } + + const audioAssets = new Map([ + [ + sourceAudioAssetId, + { + id: sourceAudioAssetId, + userId: sourceUserId, + trackId: sourceTrackId, + sha256: 'audio-sha', + storageKey: `users/${sourceUserId}/audio/audio-sha.mp3`, + originalFilename: 'legacy.mp3', + mimeType: 'audio/mpeg', + fileExtension: 'mp3', + fileSizeBytes: 1024n, + bitRateKbps: 320, + sampleRateHz: 44100, + channels: 2, + durationMs: 241000, + sourceDeviceId: null, + }, + ], + ]); + + if (options?.sourceTrackWithExistingTargetAsset) { + audioAssets.set(targetExistingAudioAssetId, { + id: targetExistingAudioAssetId, + userId: targetUserId, + trackId: null, + sha256: 'audio-sha', + storageKey: `users/${targetUserId}/audio/audio-sha.mp3`, + originalFilename: 'existing.mp3', + mimeType: 'audio/mpeg', + fileExtension: 'mp3', + fileSizeBytes: 1024n, + bitRateKbps: 320, + sampleRateHz: 44100, + channels: 2, + durationMs: 241000, + sourceDeviceId: null, + }); + } + + const tracks = new Map([ + [ + sourceTrackId, + { + id: sourceTrackId, + userId: sourceUserId, + primaryAudioAssetId: sourceAudioAssetId, + artworkAssetId: sourceArtworkId, + title: 'Legacy Track', + artist: 'Legacy Artist', + album: 'Legacy Album', + albumArtist: 'Legacy Album Artist', + genre: 'Jazz', + discNumber: 1, + trackNumber: 3, + year: 2024, + durationMs: 241000, + status: TrackStatus.ACTIVE, + deletedAt: null, + createdAt: new Date('2026-06-20T10:00:00.000Z'), + updatedAt: new Date('2026-06-21T10:00:00.000Z'), + }, + ], + ]); + + const libraryEvents: any[] = []; + const ownershipTransfers: any[] = []; + const copyStoredFileIfMissing = jest.fn().mockResolvedValue(undefined); + + const tx = { + user: { + update: jest.fn().mockImplementation(async ({ where, data, select }) => { + const user = users.get(where.id); + if (!user) { + return null; + } + + if (data.libraryCursor?.increment) { + user.libraryCursor += BigInt(data.libraryCursor.increment); + } + + if (select?.libraryCursor) { + return { + libraryCursor: user.libraryCursor, + }; + } + + return user; + }), + }, + track: { + findMany: jest.fn().mockImplementation(async ({ where }) => + Array.from(tracks.values()) + .filter( + (track) => + track.userId === where.userId && + track.status === where.status && + track.primaryAudioAssetId !== null, + ) + .map((track) => ({ + ...track, + primaryAudioAsset: audioAssets.get(track.primaryAudioAssetId) ?? null, + artworkAsset: track.artworkAssetId + ? artworkAssets.get(track.artworkAssetId) ?? null + : null, + })), + ), + create: jest.fn().mockImplementation(async ({ data }) => { + const id = randomUUID(); + const record = { + id, + userId: data.userId, + primaryAudioAssetId: data.primaryAudioAssetId ?? null, + artworkAssetId: data.artworkAssetId ?? null, + title: data.title, + artist: data.artist, + album: data.album ?? null, + albumArtist: data.albumArtist ?? null, + genre: data.genre ?? null, + discNumber: data.discNumber ?? null, + trackNumber: data.trackNumber ?? null, + year: data.year ?? null, + durationMs: data.durationMs ?? null, + status: data.status, + deletedAt: null, + createdAt: new Date('2026-06-25T10:00:00.000Z'), + updatedAt: new Date('2026-06-25T10:00:00.000Z'), + }; + tracks.set(id, record); + return record; + }), + update: jest.fn().mockImplementation(async ({ where, data, select }) => { + const track = tracks.get(where.id); + Object.assign(track, data, { + updatedAt: new Date('2026-06-25T10:05:00.000Z'), + }); + if (select) { + return { + id: track.id, + title: track.title, + artist: track.artist, + durationMs: track.durationMs, + createdAt: track.createdAt, + updatedAt: track.updatedAt, + }; + } + return track; + }), + }, + audioAsset: { + findUnique: jest.fn().mockImplementation(async ({ where, select }) => { + let asset = null; + if (where.userId_sha256) { + asset = + Array.from(audioAssets.values()).find( + (candidate) => + candidate.userId === where.userId_sha256.userId && + candidate.sha256 === where.userId_sha256.sha256, + ) ?? null; + } + + if (!asset) { + return null; + } + + const linkedTrack = + asset.trackId != null ? tracks.get(asset.trackId) ?? null : null; + + if (select) { + return { + id: asset.id, + trackId: asset.trackId, + sha256: asset.sha256, + durationMs: asset.durationMs, + originalFilename: asset.originalFilename, + mimeType: asset.mimeType, + fileExtension: asset.fileExtension, + fileSizeBytes: asset.fileSizeBytes, + bitRateKbps: asset.bitRateKbps, + sampleRateHz: asset.sampleRateHz, + channels: asset.channels, + sourceDeviceId: asset.sourceDeviceId, + storageKey: asset.storageKey, + primaryForTrack: linkedTrack + ? { + id: linkedTrack.id, + } + : null, + }; + } + + return asset; + }), + create: jest.fn().mockImplementation(async ({ data }) => { + const id = randomUUID(); + const record = { id, ...data }; + audioAssets.set(id, record); + return record; + }), + update: jest.fn().mockImplementation(async ({ where, data }) => { + const asset = audioAssets.get(where.id); + Object.assign(asset, data); + return asset; + }), + }, + artworkAsset: { + findUnique: jest.fn().mockImplementation(async ({ where }) => { + if (!where.userId_sha256) { + return null; + } + + return ( + Array.from(artworkAssets.values()).find( + (candidate) => + candidate.userId === where.userId_sha256.userId && + candidate.sha256 === where.userId_sha256.sha256, + ) ?? null + ); + }), + create: jest.fn().mockImplementation(async ({ data }) => { + const id = randomUUID(); + const record = { id, ...data }; + artworkAssets.set(id, record); + return record; + }), + }, + libraryEvent: { + create: jest.fn().mockImplementation(async ({ data }) => { + libraryEvents.push(data); + return data; + }), + }, + }; + + const prismaService = { + user: { + findUnique: jest.fn().mockImplementation(async ({ where, select }) => { + const user = users.get(where.id) ?? null; + if (!user) { + return null; + } + + if (select?.id && select?.accountKind && select?.accountStatus) { + return { + id: user.id, + accountKind: user.accountKind, + accountStatus: user.accountStatus, + }; + } + + return { + id: user.id, + }; + }), + }, + device: { + findUnique: jest.fn().mockImplementation(async ({ where }) => { + return devices.get(where.id) ?? null; + }), + }, + track: { + count: jest.fn().mockImplementation(async ({ where }) => + Array.from(tracks.values()).filter( + (track) => + track.userId === where.userId && + track.status === where.status && + track.primaryAudioAssetId !== null, + ).length, + ), + }, + ownershipTransfer: { + create: jest.fn().mockImplementation(async ({ data }) => { + const record = { + id: randomUUID(), + ...data, + completedAt: null, + }; + ownershipTransfers.push(record); + return { + id: record.id, + }; + }), + update: jest.fn().mockImplementation(async ({ where, data }) => { + const record = ownershipTransfers.find( + (candidate) => candidate.id === where.id, + ); + Object.assign(record, data); + return record; + }), + }, + $transaction: jest.fn().mockImplementation(async (callback) => callback(tx)), + } as any; + + const service = new OwnershipTransferService(prismaService, { + userAudioAssetStorageKey: jest.fn((userId: string, sha256: string) => `users/${userId}/audio/${sha256}.mp3`), + userArtworkAssetStorageKey: jest.fn( + (userId: string, sha256: string, fileExtension: string) => + `users/${userId}/artwork/${sha256}.${fileExtension}`, + ), + ensureParentDirectory: jest.fn().mockResolvedValue(undefined), + resolve: jest.fn((storageKey: string) => `/tmp/${storageKey}`), + } as any); + + jest + .spyOn(service as any, 'copyStoredFileIfMissing') + .mockImplementation(copyStoredFileIfMissing); + + return { + service, + prismaService, + tx, + tracks, + audioAssets, + artworkAssets, + users, + libraryEvents, + ownershipTransfers, + copyStoredFileIfMissing, + sourceTrackId, + sourceAudioAssetId, + }; + }; + + it('copies a legacy/default library to the target user without mutating the source track', async () => { + const { + service, + tracks, + audioAssets, + artworkAssets, + libraryEvents, + ownershipTransfers, + sourceTrackId, + sourceAudioAssetId, + copyStoredFileIfMissing, + } = buildService(); + + const result = await service.copyLegacyLibraryToTarget({ + sourceUserId, + targetUserId, + mode: 'COPY', + initiatedByUserId, + initiatedByDeviceId, + }); + + expect(result.status).toBe('COMPLETED'); + expect(result.summary.copiedTracks).toBe(1); + expect(result.summary.createdAssets).toBe(1); + expect(result.summary.createdArtwork).toBe(1); + expect(result.summary.reusedAssets).toBe(0); + expect(result.summary.reusedArtwork).toBe(0); + expect(copyStoredFileIfMissing).toHaveBeenCalledTimes(2); + + const sourceTrack = tracks.get(sourceTrackId); + expect(sourceTrack.userId).toBe(sourceUserId); + expect(sourceTrack.primaryAudioAssetId).toBe(sourceAudioAssetId); + + const targetTracks = Array.from(tracks.values()).filter( + (track) => track.userId === targetUserId, + ); + expect(targetTracks).toHaveLength(1); + expect(targetTracks[0]).toMatchObject({ + title: 'Legacy Track', + artist: 'Legacy Artist', + album: 'Legacy Album', + albumArtist: 'Legacy Album Artist', + genre: 'Jazz', + discNumber: 1, + trackNumber: 3, + year: 2024, + durationMs: 241000, + status: TrackStatus.ACTIVE, + }); + + const targetAudioAssets = Array.from(audioAssets.values()).filter( + (asset) => asset.userId === targetUserId, + ); + expect(targetAudioAssets).toHaveLength(1); + expect(targetAudioAssets[0]?.sha256).toBe('audio-sha'); + + const targetArtworkAssets = Array.from(artworkAssets.values()).filter( + (asset) => asset.userId === targetUserId, + ); + expect(targetArtworkAssets).toHaveLength(1); + expect(targetArtworkAssets[0]?.sha256).toBe('artwork-sha'); + + expect(libraryEvents).toHaveLength(1); + expect(libraryEvents[0]).toMatchObject({ + userId: targetUserId, + entityType: EntityType.TRACK, + action: EventAction.CREATED, + }); + + expect(ownershipTransfers).toHaveLength(1); + expect(ownershipTransfers[0]).toMatchObject({ + fromUserId: sourceUserId, + toUserId: targetUserId, + requestedByUserId: initiatedByUserId, + requestedByDeviceId: initiatedByDeviceId, + mode: 'COPY', + status: 'COMPLETED', + }); + expect(ownershipTransfers[0]?.summary).toMatchObject({ + copiedTracks: 1, + createdAssets: 1, + createdArtwork: 1, + storageStrategy: 'USER_SCOPED_COPY', + }); + }); + + it('dedupes target audio assets by sha256 during copy', async () => { + const { service, audioAssets } = buildService({ + sourceTrackWithExistingTargetAsset: true, + }); + + const result = await service.copyLegacyLibraryToTarget({ + sourceUserId, + targetUserId, + mode: 'COPY', + }); + + expect(result.summary.copiedTracks).toBe(1); + expect(result.summary.reusedAssets).toBe(1); + expect(result.summary.createdAssets).toBe(0); + expect( + Array.from(audioAssets.values()).filter((asset) => asset.userId === targetUserId), + ).toHaveLength(1); + }); + + it('dedupes target artwork assets by sha256 during copy', async () => { + const { service, artworkAssets } = buildService({ + sourceTrackWithExistingTargetArtwork: true, + }); + + const result = await service.copyLegacyLibraryToTarget({ + sourceUserId, + targetUserId, + mode: 'COPY', + }); + + expect(result.summary.reusedArtwork).toBe(1); + expect(result.summary.createdArtwork).toBe(0); + expect( + Array.from(artworkAssets.values()).filter( + (asset) => asset.userId === targetUserId, + ), + ).toHaveLength(1); + }); + + it('rejects same source and target users', async () => { + const { service } = buildService(); + + await expect( + service.validateTransferRequest({ + sourceUserId, + targetUserId: sourceUserId, + mode: 'COPY', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects unsupported MOVE and MERGE modes', async () => { + const { service } = buildService(); + + await expect( + service.validateTransferRequest({ + sourceUserId, + targetUserId, + mode: 'MOVE', + }), + ).rejects.toBeInstanceOf(BadRequestException); + + await expect( + service.validateTransferRequest({ + sourceUserId, + targetUserId, + mode: 'MERGE', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects inactive, locked, and deleted accounts', async () => { + await expect( + buildService({ + sourceStatus: UserAccountStatus.LOCKED, + }).service.validateTransferRequest({ + sourceUserId, + targetUserId, + mode: 'COPY', + }), + ).rejects.toBeInstanceOf(BadRequestException); + + await expect( + buildService({ + targetStatus: UserAccountStatus.DELETED, + }).service.validateTransferRequest({ + sourceUserId, + targetUserId, + mode: 'COPY', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects missing source and target users', async () => { + const { service } = buildService(); + + await expect( + service.validateTransferRequest({ + sourceUserId: randomUUID(), + targetUserId, + mode: 'COPY', + }), + ).rejects.toBeInstanceOf(NotFoundException); + + await expect( + service.validateTransferRequest({ + sourceUserId, + targetUserId: randomUUID(), + mode: 'COPY', + }), + ).rejects.toBeInstanceOf(NotFoundException); + }); +}); diff --git a/backend/src/modules/users/ownership-transfer.service.ts b/backend/src/modules/users/ownership-transfer.service.ts new file mode 100644 index 0000000..95ac34c --- /dev/null +++ b/backend/src/modules/users/ownership-transfer.service.ts @@ -0,0 +1,762 @@ +import { constants } from 'node:fs'; +import { copyFile } from 'node:fs/promises'; +import { extname } from 'node:path'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { + EntityType, + EventAction, + Prisma, + TrackStatus, + UserAccountKind, + UserAccountStatus, +} from '@prisma/client'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; +import { RemoteLibraryTrackDto } from '../library/library.dto'; +import { LocalFilesystemStorageService } from '../storage/storage.service'; + +const SUPPORTED_TRANSFER_MODE = 'COPY'; +const TRANSFER_STATUS_PENDING = 'PENDING'; +const TRANSFER_STATUS_COMPLETED = 'COMPLETED'; +const TRANSFER_STATUS_FAILED = 'FAILED'; + +type TransferClient = Pick< + Prisma.TransactionClient, + 'user' | 'track' | 'audioAsset' | 'artworkAsset' | 'libraryEvent' +>; + +interface TransferUserRecord { + id: string; + accountKind: UserAccountKind; + accountStatus: UserAccountStatus; +} + +interface SourceTrackRecord { + id: string; + userId: string; + title: string; + artist: string; + album: string | null; + albumArtist: string | null; + genre: string | null; + discNumber: number | null; + trackNumber: number | null; + year: number | null; + durationMs: number | null; + status: TrackStatus; + createdAt: Date; + updatedAt: Date; + primaryAudioAsset: { + id: string; + userId: string; + sha256: string; + storageKey: string; + originalFilename: string; + mimeType: string; + fileExtension: string; + fileSizeBytes: bigint; + bitRateKbps: number | null; + sampleRateHz: number | null; + channels: number | null; + durationMs: number | null; + sourceDeviceId: string | null; + } | null; + artworkAsset: { + id: string; + userId: string; + sha256: string; + storageKey: string; + mimeType: string; + width: number | null; + height: number | null; + fileSizeBytes: bigint; + } | null; +} + +export interface OwnershipTransferRequestInput { + sourceUserId: string; + targetUserId: string; + mode: string; + initiatedByUserId?: string | null; + initiatedByDeviceId?: string | null; +} + +export interface ValidatedOwnershipTransferRequest { + sourceUser: TransferUserRecord; + targetUser: TransferUserRecord; + mode: typeof SUPPORTED_TRANSFER_MODE; + initiatedByUserId: string | null; + initiatedByDeviceId: string | null; + preferredSourceAccount: boolean; +} + +export interface OwnershipTransferSummary { + copiedTracks: number; + reusedAssets: number; + createdAssets: number; + reusedArtwork: number; + createdArtwork: number; + skippedTracks: number; + errors: string[]; + sourceTrackCount: number; + targetEventCount: number; + sourceAccountKind: UserAccountKind; + targetAccountKind: UserAccountKind; + preferredSourceAccount: boolean; + storageStrategy: 'USER_SCOPED_COPY'; +} + +export interface OwnershipTransferPlan { + sourceUserId: string; + targetUserId: string; + mode: typeof SUPPORTED_TRANSFER_MODE; + sourceTrackCount: number; + preferredSourceAccount: boolean; + initiatedByUserId: string | null; + initiatedByDeviceId: string | null; +} + +export interface OwnershipTransferCopyResult { + transferId: string; + sourceUserId: string; + targetUserId: string; + mode: typeof SUPPORTED_TRANSFER_MODE; + status: typeof TRANSFER_STATUS_COMPLETED; + summary: OwnershipTransferSummary; +} + +@Injectable() +export class OwnershipTransferService { + constructor( + private readonly prismaService: PrismaService, + private readonly storageService: LocalFilesystemStorageService, + ) {} + + async validateTransferRequest( + request: OwnershipTransferRequestInput, + ): Promise { + if (request.sourceUserId === request.targetUserId) { + throw new BadRequestException( + 'Source and target accounts must be different.', + ); + } + + if (request.mode !== SUPPORTED_TRANSFER_MODE) { + throw new BadRequestException( + `Transfer mode ${request.mode} is not supported.`, + ); + } + + const [sourceUser, targetUser, initiatedByUser, initiatedByDevice] = + await Promise.all([ + this.prismaService.user.findUnique({ + where: { id: request.sourceUserId }, + select: { + id: true, + accountKind: true, + accountStatus: true, + }, + }), + this.prismaService.user.findUnique({ + where: { id: request.targetUserId }, + select: { + id: true, + accountKind: true, + accountStatus: true, + }, + }), + request.initiatedByUserId + ? this.prismaService.user.findUnique({ + where: { id: request.initiatedByUserId }, + select: { id: true }, + }) + : Promise.resolve(null), + request.initiatedByDeviceId + ? this.prismaService.device.findUnique({ + where: { id: request.initiatedByDeviceId }, + select: { id: true }, + }) + : Promise.resolve(null), + ]); + + if (!sourceUser) { + throw new NotFoundException('Source account not found'); + } + + if (!targetUser) { + throw new NotFoundException('Target account not found'); + } + + if (sourceUser.accountStatus !== UserAccountStatus.ACTIVE) { + throw new BadRequestException('Source account is not active'); + } + + if (targetUser.accountStatus !== UserAccountStatus.ACTIVE) { + throw new BadRequestException('Target account is not active'); + } + + if (request.initiatedByUserId && !initiatedByUser) { + throw new NotFoundException('Initiating account not found'); + } + + if (request.initiatedByDeviceId && !initiatedByDevice) { + throw new NotFoundException('Initiating device not found'); + } + + return { + sourceUser, + targetUser, + mode: SUPPORTED_TRANSFER_MODE, + initiatedByUserId: initiatedByUser?.id ?? null, + initiatedByDeviceId: initiatedByDevice?.id ?? null, + preferredSourceAccount: + sourceUser.accountKind === UserAccountKind.LEGACY_DEFAULT || + sourceUser.accountKind === UserAccountKind.GUEST, + }; + } + + async createTransferPlan( + request: OwnershipTransferRequestInput, + ): Promise { + const validatedRequest = await this.validateTransferRequest(request); + const sourceTrackCount = await this.prismaService.track.count({ + where: { + userId: validatedRequest.sourceUser.id, + status: TrackStatus.ACTIVE, + primaryAudioAssetId: { + not: null, + }, + }, + }); + + return { + sourceUserId: validatedRequest.sourceUser.id, + targetUserId: validatedRequest.targetUser.id, + mode: validatedRequest.mode, + sourceTrackCount, + preferredSourceAccount: validatedRequest.preferredSourceAccount, + initiatedByUserId: validatedRequest.initiatedByUserId, + initiatedByDeviceId: validatedRequest.initiatedByDeviceId, + }; + } + + async copyLegacyLibraryToTarget( + request: OwnershipTransferRequestInput, + ): Promise { + const [validatedRequest, plan] = await Promise.all([ + this.validateTransferRequest(request), + this.createTransferPlan(request), + ]); + + const transfer = await this.prismaService.ownershipTransfer.create({ + data: { + fromUserId: validatedRequest.sourceUser.id, + toUserId: validatedRequest.targetUser.id, + requestedByUserId: validatedRequest.initiatedByUserId, + requestedByDeviceId: validatedRequest.initiatedByDeviceId, + mode: validatedRequest.mode, + status: TRANSFER_STATUS_PENDING, + summary: this.toTransferSummaryJson({ + copiedTracks: 0, + reusedAssets: 0, + createdAssets: 0, + reusedArtwork: 0, + createdArtwork: 0, + skippedTracks: 0, + errors: [], + sourceTrackCount: plan.sourceTrackCount, + targetEventCount: 0, + sourceAccountKind: validatedRequest.sourceUser.accountKind, + targetAccountKind: validatedRequest.targetUser.accountKind, + preferredSourceAccount: validatedRequest.preferredSourceAccount, + storageStrategy: 'USER_SCOPED_COPY', + }), + }, + select: { + id: true, + }, + }); + + try { + const summary = await this.prismaService.$transaction(async (tx) => { + const sourceTracks = await tx.track.findMany({ + where: { + userId: validatedRequest.sourceUser.id, + status: TrackStatus.ACTIVE, + primaryAudioAssetId: { + not: null, + }, + }, + orderBy: { + createdAt: 'asc', + }, + select: { + id: true, + userId: true, + title: true, + artist: true, + album: true, + albumArtist: true, + genre: true, + discNumber: true, + trackNumber: true, + year: true, + durationMs: true, + status: true, + createdAt: true, + updatedAt: true, + primaryAudioAsset: { + select: { + id: true, + userId: true, + sha256: true, + storageKey: true, + originalFilename: true, + mimeType: true, + fileExtension: true, + fileSizeBytes: true, + bitRateKbps: true, + sampleRateHz: true, + channels: true, + durationMs: true, + sourceDeviceId: true, + }, + }, + artworkAsset: { + select: { + id: true, + userId: true, + sha256: true, + storageKey: true, + mimeType: true, + width: true, + height: true, + fileSizeBytes: true, + }, + }, + }, + }); + + const summary: OwnershipTransferSummary = { + copiedTracks: 0, + reusedAssets: 0, + createdAssets: 0, + reusedArtwork: 0, + createdArtwork: 0, + skippedTracks: 0, + errors: [], + sourceTrackCount: sourceTracks.length, + targetEventCount: 0, + sourceAccountKind: validatedRequest.sourceUser.accountKind, + targetAccountKind: validatedRequest.targetUser.accountKind, + preferredSourceAccount: validatedRequest.preferredSourceAccount, + storageStrategy: 'USER_SCOPED_COPY', + }; + + for (const sourceTrack of sourceTracks as SourceTrackRecord[]) { + if (!sourceTrack.primaryAudioAsset) { + summary.skippedTracks += 1; + continue; + } + + const existingTargetAudioAsset = await tx.audioAsset.findUnique({ + where: { + userId_sha256: { + userId: validatedRequest.targetUser.id, + sha256: sourceTrack.primaryAudioAsset.sha256, + }, + }, + select: { + id: true, + trackId: true, + sha256: true, + durationMs: true, + originalFilename: true, + mimeType: true, + fileExtension: true, + fileSizeBytes: true, + bitRateKbps: true, + sampleRateHz: true, + channels: true, + sourceDeviceId: true, + storageKey: true, + primaryForTrack: { + select: { + id: true, + }, + }, + }, + }); + + if (existingTargetAudioAsset?.trackId || existingTargetAudioAsset?.primaryForTrack) { + summary.skippedTracks += 1; + continue; + } + + const targetArtworkAsset = sourceTrack.artworkAsset + ? await this.findOrCreateTargetArtworkAsset( + tx, + validatedRequest.targetUser.id, + sourceTrack.artworkAsset, + summary, + ) + : null; + + const createdTrack = await tx.track.create({ + data: { + userId: validatedRequest.targetUser.id, + title: sourceTrack.title, + artist: sourceTrack.artist, + album: sourceTrack.album, + albumArtist: sourceTrack.albumArtist, + genre: sourceTrack.genre, + discNumber: sourceTrack.discNumber, + trackNumber: sourceTrack.trackNumber, + year: sourceTrack.year, + durationMs: sourceTrack.durationMs, + status: TrackStatus.ACTIVE, + artworkAssetId: targetArtworkAsset?.id ?? null, + }, + }); + + const targetAudioAsset = existingTargetAudioAsset + ? await tx.audioAsset.update({ + where: { + id: existingTargetAudioAsset.id, + }, + data: { + trackId: createdTrack.id, + }, + }) + : await this.createTargetAudioAsset( + tx, + validatedRequest.targetUser.id, + createdTrack.id, + sourceTrack.primaryAudioAsset, + summary, + ); + + if (existingTargetAudioAsset) { + summary.reusedAssets += 1; + } + + const finalizedTrack = await tx.track.update({ + where: { + id: createdTrack.id, + }, + data: { + primaryAudioAssetId: targetAudioAsset.id, + }, + select: { + id: true, + title: true, + artist: true, + durationMs: true, + createdAt: true, + updatedAt: true, + }, + }); + + await this.appendLibraryEvent(tx, { + userId: validatedRequest.targetUser.id, + entityType: EntityType.TRACK, + entityId: finalizedTrack.id, + action: EventAction.CREATED, + payload: { + track: this.buildRemoteLibraryTrackDto( + finalizedTrack, + { + id: targetAudioAsset.id, + sha256: targetAudioAsset.sha256, + durationMs: targetAudioAsset.durationMs, + }, + targetArtworkAsset + ? { + id: targetArtworkAsset.id, + sha256: targetArtworkAsset.sha256, + mimeType: targetArtworkAsset.mimeType, + width: targetArtworkAsset.width, + height: targetArtworkAsset.height, + } + : null, + ), + }, + }); + + summary.copiedTracks += 1; + summary.targetEventCount += 1; + } + + return summary; + }); + + await this.markTransferCompleted(transfer.id, summary); + + return { + transferId: transfer.id, + sourceUserId: validatedRequest.sourceUser.id, + targetUserId: validatedRequest.targetUser.id, + mode: validatedRequest.mode, + status: TRANSFER_STATUS_COMPLETED, + summary, + }; + } catch (error) { + const message = + error instanceof Error ? error.message : 'Transfer failed unexpectedly.'; + await this.markTransferFailed(transfer.id, message); + throw error; + } + } + + async markTransferCompleted( + transferId: string, + summary: OwnershipTransferSummary, + ): Promise { + await this.prismaService.ownershipTransfer.update({ + where: { + id: transferId, + }, + data: { + status: TRANSFER_STATUS_COMPLETED, + completedAt: new Date(), + summary: this.toTransferSummaryJson(summary), + }, + }); + } + + async markTransferFailed( + transferId: string, + errorMessage: string, + ): Promise { + await this.prismaService.ownershipTransfer.update({ + where: { + id: transferId, + }, + data: { + status: TRANSFER_STATUS_FAILED, + summary: { + errors: [errorMessage], + storageStrategy: 'USER_SCOPED_COPY', + }, + }, + }); + } + + private async createTargetAudioAsset( + tx: Pick, + targetUserId: string, + trackId: string, + sourceAudioAsset: NonNullable, + summary: OwnershipTransferSummary, + ) { + const targetStorageKey = this.storageService.userAudioAssetStorageKey( + targetUserId, + sourceAudioAsset.sha256, + ); + + await this.copyStoredFileIfMissing( + sourceAudioAsset.storageKey, + targetStorageKey, + ); + + summary.createdAssets += 1; + + return tx.audioAsset.create({ + data: { + userId: targetUserId, + trackId, + sha256: sourceAudioAsset.sha256, + storageKey: targetStorageKey, + originalFilename: sourceAudioAsset.originalFilename, + mimeType: sourceAudioAsset.mimeType, + fileExtension: sourceAudioAsset.fileExtension, + fileSizeBytes: sourceAudioAsset.fileSizeBytes, + bitRateKbps: sourceAudioAsset.bitRateKbps, + sampleRateHz: sourceAudioAsset.sampleRateHz, + channels: sourceAudioAsset.channels, + durationMs: sourceAudioAsset.durationMs, + sourceDeviceId: sourceAudioAsset.sourceDeviceId, + }, + }); + } + + private async findOrCreateTargetArtworkAsset( + tx: Pick, + targetUserId: string, + sourceArtworkAsset: NonNullable, + summary: OwnershipTransferSummary, + ) { + const existingArtworkAsset = await tx.artworkAsset.findUnique({ + where: { + userId_sha256: { + userId: targetUserId, + sha256: sourceArtworkAsset.sha256, + }, + }, + }); + + if (existingArtworkAsset) { + summary.reusedArtwork += 1; + return existingArtworkAsset; + } + + const extension = extname(sourceArtworkAsset.storageKey).replace('.', ''); + const targetStorageKey = this.storageService.userArtworkAssetStorageKey( + targetUserId, + sourceArtworkAsset.sha256, + extension, + ); + + await this.copyStoredFileIfMissing( + sourceArtworkAsset.storageKey, + targetStorageKey, + ); + + summary.createdArtwork += 1; + + return tx.artworkAsset.create({ + data: { + userId: targetUserId, + sha256: sourceArtworkAsset.sha256, + storageKey: targetStorageKey, + mimeType: sourceArtworkAsset.mimeType, + width: sourceArtworkAsset.width, + height: sourceArtworkAsset.height, + fileSizeBytes: sourceArtworkAsset.fileSizeBytes, + }, + }); + } + + private buildRemoteLibraryTrackDto( + track: { + id: string; + title: string; + artist: string; + durationMs: number | null; + createdAt: Date; + updatedAt: Date; + }, + audioAsset: { + id: string; + sha256: string; + durationMs: number | null; + }, + artworkAsset: { + id: string; + sha256: string; + mimeType: string; + width: number | null; + height: number | null; + } | null, + ): RemoteLibraryTrackDto { + const durationMs = track.durationMs ?? audioAsset.durationMs ?? 0; + + return { + trackId: track.id, + title: track.title, + artist: track.artist, + durationSeconds: Math.max(0, Math.round(durationMs / 1000)), + sha256: audioAsset.sha256, + assetId: audioAsset.id, + createdAt: track.createdAt.toISOString(), + updatedAt: track.updatedAt.toISOString(), + artwork: artworkAsset + ? { + artworkId: artworkAsset.id, + sha256: artworkAsset.sha256, + mimeType: artworkAsset.mimeType, + width: artworkAsset.width, + height: artworkAsset.height, + } + : null, + }; + } + + private async appendLibraryEvent( + tx: TransferClient, + params: { + userId: string; + entityType: EntityType; + entityId: string; + action: EventAction; + payload: { + track?: RemoteLibraryTrackDto; + deletedTrackId?: string; + }; + }, + ): Promise { + const owner = await tx.user.update({ + where: { + id: params.userId, + }, + data: { + libraryCursor: { + increment: 1, + }, + }, + select: { + libraryCursor: true, + }, + }); + + await tx.libraryEvent.create({ + data: { + userId: params.userId, + cursor: owner.libraryCursor, + entityType: params.entityType, + entityId: params.entityId, + action: params.action, + payload: params.payload as Prisma.InputJsonValue, + }, + }); + } + + private async copyStoredFileIfMissing( + sourceStorageKey: string, + targetStorageKey: string, + ): Promise { + const sourcePath = this.storageService.resolve(sourceStorageKey); + const targetPath = this.storageService.resolve(targetStorageKey); + + await this.storageService.ensureParentDirectory(targetPath); + + if (sourcePath === targetPath) { + return; + } + + try { + await copyFile(sourcePath, targetPath, constants.COPYFILE_EXCL); + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + typeof error.code === 'string' && + error.code === 'EEXIST' + ) { + return; + } + + if ( + error instanceof Error && + 'code' in error && + typeof error.code === 'string' && + error.code === 'ENOENT' + ) { + throw new NotFoundException( + `Source storage object ${sourceStorageKey} is missing.`, + ); + } + + throw error; + } + } + + private toTransferSummaryJson( + summary: OwnershipTransferSummary, + ): Prisma.InputJsonValue { + return summary as unknown as Prisma.InputJsonValue; + } +} diff --git a/backend/src/modules/users/users.module.ts b/backend/src/modules/users/users.module.ts index 73ddd65..281598e 100644 --- a/backend/src/modules/users/users.module.ts +++ b/backend/src/modules/users/users.module.ts @@ -6,24 +6,33 @@ import { AccountController } from './account.controller'; import { AccountService } from './account.service'; import { DefaultUserService } from './default-user.service'; import { DeviceLinkingService } from './device-linking.service'; +import { OwnershipTransferService } from './ownership-transfer.service'; import { BootstrapOwnerContextService, OwnerContext, } from './owner-context.service'; +import { StorageModule } from '../storage/storage.module'; @Module({ - imports: [PrismaModule, RequestContextModule, AuthModule], + imports: [PrismaModule, RequestContextModule, AuthModule, StorageModule], controllers: [AccountController], providers: [ AccountService, DefaultUserService, DeviceLinkingService, + OwnershipTransferService, BootstrapOwnerContextService, { provide: OwnerContext, useExisting: BootstrapOwnerContextService, }, ], - exports: [DefaultUserService, OwnerContext, AccountService, DeviceLinkingService], + exports: [ + DefaultUserService, + OwnerContext, + AccountService, + DeviceLinkingService, + OwnershipTransferService, + ], }) export class UsersModule {}