openreader/src/lib/server/user/claim-data.ts
Richard R e0cd9bc270 feat(user): remove legacy local file deletion and add cleanup for claimed FS sources
Eliminate the unused --delete-local option and related code from the
filesystem migration script to prevent accidental local data removal.
Introduce a cleanupClaimedLegacyFsSources utility for removing legacy
filesystem sources after claim, and integrate it into the user claim
flow. Add unit tests to verify the cleanup logic.
2026-06-03 14:35:27 -06:00

311 lines
9.3 KiB
TypeScript

import { db } from '@/db';
import { documents, audiobooks, audiobookChapters, userPreferences, userDocumentProgress } from '@/db/schema';
import { eq, and, inArray } from 'drizzle-orm';
import { UNCLAIMED_USER_ID } from '../storage/docstore-legacy';
import { cleanupClaimedLegacyFsSources } from './legacy-fs-claim-cleanup';
import {
deleteAudiobookObject,
getAudiobookObjectBuffer,
listAudiobookObjects,
putAudiobookObject,
} from '../audiobooks/blobstore';
import { isS3Configured } from '../storage/s3';
import { logDegraded } from '../errors/logging';
import { hashForLog, serverLogger } from '../logger';
type AudiobookRow = {
id: string;
userId: string;
title: string;
author: string | null;
description: string | null;
coverPath: string | null;
duration: number | null;
createdAt: number;
};
type AudiobookChapterRow = {
id: string;
bookId: string;
userId: string;
chapterIndex: number;
title: string;
duration: number | null;
filePath: string;
format: string;
};
type UserPreferenceRow = {
userId: string;
dataJson: unknown;
clientUpdatedAtMs: number;
createdAt: number;
updatedAt: number;
};
type UserDocumentProgressRow = {
userId: string;
documentId: string;
readerType: string;
location: string;
progress: number | null;
clientUpdatedAtMs: number;
createdAt: number;
updatedAt: number;
};
function contentTypeForAudiobookObject(fileName: string): string {
if (fileName.endsWith('.mp3')) return 'audio/mpeg';
if (fileName.endsWith('.m4b')) return 'audio/mp4';
if (fileName.endsWith('.json')) return 'application/json; charset=utf-8';
return 'application/octet-stream';
}
async function moveAudiobookBlobScope(
bookId: string,
fromUserId: string,
toUserId: string,
namespace: string | null,
): Promise<void> {
if (fromUserId === toUserId) return;
const objects = await listAudiobookObjects(bookId, fromUserId, namespace);
if (objects.length === 0) return;
for (const object of objects) {
const bytes = await getAudiobookObjectBuffer(bookId, fromUserId, object.fileName, namespace);
await putAudiobookObject(
bookId,
toUserId,
object.fileName,
bytes,
contentTypeForAudiobookObject(object.fileName),
namespace,
);
}
for (const object of objects) {
await deleteAudiobookObject(bookId, fromUserId, object.fileName, namespace).catch(() => {});
}
}
export async function claimAnonymousData(userId: string, unclaimedUserId: string = UNCLAIMED_USER_ID, namespace: string | null = null) {
if (!userId) {
return { documents: 0, audiobooks: 0, preferences: 0, progress: 0 };
}
const [claimableDocumentRows, claimableAudiobookRows] = await Promise.all([
db
.select({ id: documents.id })
.from(documents)
.where(eq(documents.userId, unclaimedUserId)) as Promise<Array<{ id: string }>>,
db
.select({ id: audiobooks.id })
.from(audiobooks)
.where(eq(audiobooks.userId, unclaimedUserId)) as Promise<Array<{ id: string }>>,
]);
const [documentsClaimed, audiobooksClaimed, preferencesClaimed, progressClaimed] = await Promise.all([
transferUserDocuments(unclaimedUserId, userId),
transferUserAudiobooks(unclaimedUserId, userId, namespace),
transferUserPreferences(unclaimedUserId, userId),
transferUserProgress(unclaimedUserId, userId),
]);
if (claimableDocumentRows.length > 0 || claimableAudiobookRows.length > 0) {
await cleanupClaimedLegacyFsSources({
documentIds: claimableDocumentRows.map((row) => row.id),
audiobookIds: claimableAudiobookRows.map((row) => row.id),
namespace,
}).catch((error) => {
logDegraded(serverLogger, {
event: 'user.claim.legacy_fs_cleanup.failed',
msg: 'Failed to remove claimed legacy filesystem sources',
step: 'cleanup_claimed_legacy_fs_sources',
context: {
claimedUserIdHash: hashForLog(userId),
unclaimedUserIdHash: hashForLog(unclaimedUserId),
documentCount: claimableDocumentRows.length,
audiobookCount: claimableAudiobookRows.length,
namespace,
},
error,
});
});
}
return {
documents: documentsClaimed,
audiobooks: audiobooksClaimed,
preferences: preferencesClaimed,
progress: progressClaimed,
};
}
/**
* Transfer documents from one userId to another.
*
* This is used when an anonymous user upgrades to an authenticated account.
* The underlying blob storage is shared (by sha), so this only moves metadata rows.
*
* @returns number of document rows transferred
*/
export async function transferUserDocuments(
fromUserId: string,
toUserId: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
options?: { db?: any },
): Promise<number> {
if (!fromUserId || !toUserId) return 0;
if (fromUserId === toUserId) return 0;
const database = options?.db ?? db;
const rows = await database.select().from(documents).where(eq(documents.userId, fromUserId));
if (rows.length === 0) return 0;
await database
.insert(documents)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.values(rows.map((row: any) => ({ ...row, userId: toUserId })))
.onConflictDoNothing();
await database.delete(documents).where(eq(documents.userId, fromUserId));
return rows.length;
}
/**
* Transfer audiobooks from one user to another.
* Used when an anonymous user creates a real account.
* @returns number of audiobooks transferred
*/
export async function transferUserAudiobooks(
fromUserId: string,
toUserId: string,
namespace: string | null = null,
): Promise<number> {
if (!fromUserId || !toUserId) return 0;
if (fromUserId === toUserId) return 0;
const books = (await db
.select()
.from(audiobooks)
.where(eq(audiobooks.userId, fromUserId))) as AudiobookRow[];
if (books.length === 0) return 0;
if (isS3Configured()) {
for (const book of books) {
await moveAudiobookBlobScope(book.id, fromUserId, toUserId, namespace);
}
}
await db
.insert(audiobooks)
.values(books.map((book) => ({ ...book, userId: toUserId })))
.onConflictDoNothing();
const chapters = (await db
.select()
.from(audiobookChapters)
.where(eq(audiobookChapters.userId, fromUserId))) as AudiobookChapterRow[];
if (chapters.length > 0) {
await db
.insert(audiobookChapters)
.values(chapters.map((chapter) => ({ ...chapter, userId: toUserId })))
.onConflictDoNothing();
}
await db.delete(audiobookChapters).where(eq(audiobookChapters.userId, fromUserId));
await db.delete(audiobooks).where(eq(audiobooks.userId, fromUserId));
return books.length;
}
export async function transferUserPreferences(fromUserId: string, toUserId: string): Promise<number> {
if (!fromUserId || !toUserId) return 0;
if (fromUserId === toUserId) return 0;
const fromRows = (await db
.select()
.from(userPreferences)
.where(eq(userPreferences.userId, fromUserId))) as UserPreferenceRow[];
const fromRow = fromRows[0];
if (!fromRow) return 0;
const toRows = (await db
.select()
.from(userPreferences)
.where(eq(userPreferences.userId, toUserId))) as UserPreferenceRow[];
const toRow = toRows[0];
if (!toRow || Number(fromRow.clientUpdatedAtMs ?? 0) > Number(toRow.clientUpdatedAtMs ?? 0)) {
await db
.insert(userPreferences)
.values({
...fromRow,
userId: toUserId,
})
.onConflictDoUpdate({
target: [userPreferences.userId],
set: {
dataJson: fromRow.dataJson,
clientUpdatedAtMs: fromRow.clientUpdatedAtMs,
updatedAt: fromRow.updatedAt,
},
});
}
await db.delete(userPreferences).where(eq(userPreferences.userId, fromUserId));
return 1;
}
export async function transferUserProgress(fromUserId: string, toUserId: string): Promise<number> {
if (!fromUserId || !toUserId) return 0;
if (fromUserId === toUserId) return 0;
const fromRows = (await db
.select()
.from(userDocumentProgress)
.where(eq(userDocumentProgress.userId, fromUserId))) as UserDocumentProgressRow[];
if (fromRows.length === 0) return 0;
const documentIds = fromRows.map((row) => row.documentId);
const toRows = (await db
.select()
.from(userDocumentProgress)
.where(and(
eq(userDocumentProgress.userId, toUserId),
inArray(userDocumentProgress.documentId, documentIds),
))) as UserDocumentProgressRow[];
const toByDocId = new Map<string, UserDocumentProgressRow>();
for (const row of toRows) {
toByDocId.set(row.documentId, row);
}
for (const row of fromRows) {
const existing = toByDocId.get(row.documentId);
const fromUpdated = Number(row.clientUpdatedAtMs ?? 0);
const toUpdated = Number(existing?.clientUpdatedAtMs ?? 0);
if (existing && fromUpdated <= toUpdated) continue;
await db
.insert(userDocumentProgress)
.values({
...row,
userId: toUserId,
})
.onConflictDoUpdate({
target: [userDocumentProgress.userId, userDocumentProgress.documentId],
set: {
readerType: row.readerType,
location: row.location,
progress: row.progress,
clientUpdatedAtMs: row.clientUpdatedAtMs,
updatedAt: row.updatedAt,
},
});
}
await db.delete(userDocumentProgress).where(eq(userDocumentProgress.userId, fromUserId));
return fromRows.length;
}