Add ownership transfer foundation

This commit is contained in:
diyaa 2026-06-25 04:20:39 +02:00
parent 488b219ef9
commit a74ff58083
6 changed files with 1432 additions and 2 deletions

View File

@ -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;

View File

@ -32,6 +32,7 @@ model User {
deviceLinkHistory DeviceLinkHistory[] deviceLinkHistory DeviceLinkHistory[]
outgoingTransfers OwnershipTransfer[] @relation("OwnershipTransferFromUser") outgoingTransfers OwnershipTransfer[] @relation("OwnershipTransferFromUser")
incomingTransfers OwnershipTransfer[] @relation("OwnershipTransferToUser") incomingTransfers OwnershipTransfer[] @relation("OwnershipTransferToUser")
initiatedTransfers OwnershipTransfer[] @relation("OwnershipTransferRequestedByUser")
@@map("users") @@map("users")
} }
@ -262,17 +263,22 @@ model OwnershipTransfer {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
fromUserId String @map("from_user_id") @db.Uuid fromUserId String @map("from_user_id") @db.Uuid
toUserId String? @map("to_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 requestedByDeviceId String? @map("requested_by_device_id") @db.Uuid
mode String @default("COPY")
status String status String
summary Json? @map("summary")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
completedAt DateTime? @map("completed_at") completedAt DateTime? @map("completed_at")
fromUser User @relation("OwnershipTransferFromUser", fields: [fromUserId], references: [id], onDelete: Cascade, onUpdate: Cascade) fromUser User @relation("OwnershipTransferFromUser", fields: [fromUserId], references: [id], onDelete: Cascade, onUpdate: Cascade)
toUser User? @relation("OwnershipTransferToUser", fields: [toUserId], references: [id], onDelete: SetNull, 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) requestedByDevice Device? @relation("OwnershipTransferRequestedByDevice", fields: [requestedByDeviceId], references: [id], onDelete: SetNull, onUpdate: Cascade)
@@index([fromUserId]) @@index([fromUserId])
@@index([toUserId]) @@index([toUserId])
@@index([requestedByUserId])
@@map("ownership_transfers") @@map("ownership_transfers")
} }

View File

@ -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"`,
);
});
});

View File

@ -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<string, any>([
[
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<string, any>([
[initiatedByDeviceId, { id: initiatedByDeviceId }],
]);
const sourceArtworkId = randomUUID();
const targetArtworkId = randomUUID();
const sourceTrackId = randomUUID();
const targetExistingAudioAssetId = randomUUID();
const sourceAudioAssetId = randomUUID();
const artworkAssets = new Map<string, any>([
[
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<string, any>([
[
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<string, any>([
[
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);
});
});

View File

@ -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<ValidatedOwnershipTransferRequest> {
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<OwnershipTransferPlan> {
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<OwnershipTransferCopyResult> {
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<void> {
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<void> {
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<Prisma.TransactionClient, 'audioAsset'>,
targetUserId: string,
trackId: string,
sourceAudioAsset: NonNullable<SourceTrackRecord['primaryAudioAsset']>,
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<Prisma.TransactionClient, 'artworkAsset'>,
targetUserId: string,
sourceArtworkAsset: NonNullable<SourceTrackRecord['artworkAsset']>,
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<void> {
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<void> {
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;
}
}

View File

@ -6,24 +6,33 @@ import { AccountController } from './account.controller';
import { AccountService } from './account.service'; import { AccountService } from './account.service';
import { DefaultUserService } from './default-user.service'; import { DefaultUserService } from './default-user.service';
import { DeviceLinkingService } from './device-linking.service'; import { DeviceLinkingService } from './device-linking.service';
import { OwnershipTransferService } from './ownership-transfer.service';
import { import {
BootstrapOwnerContextService, BootstrapOwnerContextService,
OwnerContext, OwnerContext,
} from './owner-context.service'; } from './owner-context.service';
import { StorageModule } from '../storage/storage.module';
@Module({ @Module({
imports: [PrismaModule, RequestContextModule, AuthModule], imports: [PrismaModule, RequestContextModule, AuthModule, StorageModule],
controllers: [AccountController], controllers: [AccountController],
providers: [ providers: [
AccountService, AccountService,
DefaultUserService, DefaultUserService,
DeviceLinkingService, DeviceLinkingService,
OwnershipTransferService,
BootstrapOwnerContextService, BootstrapOwnerContextService,
{ {
provide: OwnerContext, provide: OwnerContext,
useExisting: BootstrapOwnerContextService, useExisting: BootstrapOwnerContextService,
}, },
], ],
exports: [DefaultUserService, OwnerContext, AccountService, DeviceLinkingService], exports: [
DefaultUserService,
OwnerContext,
AccountService,
DeviceLinkingService,
OwnershipTransferService,
],
}) })
export class UsersModule {} export class UsersModule {}