Add ownership transfer foundation
This commit is contained in:
parent
488b219ef9
commit
a74ff58083
@ -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;
|
||||||
@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
619
backend/src/modules/users/ownership-transfer.service.spec.ts
Normal file
619
backend/src/modules/users/ownership-transfer.service.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
762
backend/src/modules/users/ownership-transfer.service.ts
Normal file
762
backend/src/modules/users/ownership-transfer.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user