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[]
|
||||
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")
|
||||
}
|
||||
|
||||
|
||||
@ -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 { 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 {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user