feat(db): add system user seeding and storage cleanup
- Implement ensureSystemUserExists to handle foreign key constraints for unclaimed data when authentication is disabled. - Add missing foreign key relationship between audiobook chapters and audiobooks with cascade delete. - Integrate a beforeDelete hook in auth config to trigger storage cleanup when a user is removed. - Regenerate initial migrations for both PostgreSQL and SQLite to reflect schema updates.
This commit is contained in:
parent
d3c7831f70
commit
8cb2929116
12 changed files with 257 additions and 78 deletions
|
|
@ -134,6 +134,7 @@ CREATE TABLE "verification" (
|
|||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "audiobook_chapters" ADD CONSTRAINT "audiobook_chapters_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "audiobook_chapters" ADD CONSTRAINT "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk" FOREIGN KEY ("book_id","user_id") REFERENCES "public"."audiobooks"("id","user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "audiobooks" ADD CONSTRAINT "audiobooks_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "documents" ADD CONSTRAINT "documents_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_document_progress" ADD CONSTRAINT "user_document_progress_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"id": "369ec851-a528-4b18-a4c9-b8ea703283ca",
|
||||
"id": "ad9a34b6-48d9-4e3a-95cd-38941cb2e055",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
|
|
@ -72,6 +72,21 @@
|
|||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk": {
|
||||
"name": "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk",
|
||||
"tableFrom": "audiobook_chapters",
|
||||
"tableTo": "audiobooks",
|
||||
"columnsFrom": [
|
||||
"book_id",
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id",
|
||||
"user_id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1771185356470,
|
||||
"tag": "0000_stormy_zaladane",
|
||||
"when": 1771188820113,
|
||||
"tag": "0000_famous_justin_hammer",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ CREATE TABLE `audiobook_chapters` (
|
|||
`file_path` text NOT NULL,
|
||||
`format` text NOT NULL,
|
||||
PRIMARY KEY(`id`, `user_id`),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`book_id`,`user_id`) REFERENCES `audiobooks`(`id`,`user_id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `audiobooks` (
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "a55d11c6-847c-4e19-9a7c-fbc2401df061",
|
||||
"id": "d7c41fe0-7d08-42af-8f53-d95fd35776c2",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"audiobook_chapters": {
|
||||
|
|
@ -79,6 +79,21 @@
|
|||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk": {
|
||||
"name": "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk",
|
||||
"tableFrom": "audiobook_chapters",
|
||||
"tableTo": "audiobooks",
|
||||
"columnsFrom": [
|
||||
"book_id",
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id",
|
||||
"user_id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1771185356176,
|
||||
"tag": "0000_furry_dragon_man",
|
||||
"when": 1771188819810,
|
||||
"tag": "0000_nervous_preak",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { drizzle as drizzlePg } from 'drizzle-orm/node-postgres';
|
||||
import { drizzle as drizzleSqlite } from 'drizzle-orm/better-sqlite3';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { Pool } from 'pg';
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
|
|
@ -8,13 +9,53 @@ import * as schema from './schema';
|
|||
import * as authSchemaSqlite from './schema_auth_sqlite';
|
||||
import * as authSchemaPostgres from './schema_auth_postgres';
|
||||
|
||||
const UNCLAIMED_USER_ID = 'unclaimed';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let dbInstance: any = null;
|
||||
let dbIsPostgres = false;
|
||||
|
||||
/** Track which system user IDs we have already ensured exist this process. */
|
||||
const seededUserIds = new Set<string>();
|
||||
|
||||
/**
|
||||
* Ensure a system/placeholder user row exists in the user table for `userId`.
|
||||
* All user-facing tables now have userId foreign keys with ON DELETE CASCADE
|
||||
* referencing the user table. When auth is disabled the app stores data under
|
||||
* the 'unclaimed' userId (and namespace variants like 'unclaimed::ns'), so
|
||||
* those rows must exist before any data can be inserted.
|
||||
*
|
||||
* This is safe to call repeatedly — it short-circuits via an in-memory Set
|
||||
* and uses INSERT … ON CONFLICT/OR IGNORE at the SQL level.
|
||||
*/
|
||||
export function ensureSystemUserExists(userId: string) {
|
||||
if (seededUserIds.has(userId)) return;
|
||||
const drizzleDb = getDrizzleDB();
|
||||
try {
|
||||
if (dbIsPostgres) {
|
||||
drizzleDb.execute(sql`
|
||||
INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at, is_anonymous)
|
||||
VALUES (${userId}, 'System User', ${userId + '@local'}, false, now(), now(), false)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`).catch(() => { /* table may not exist yet */ });
|
||||
} else {
|
||||
drizzleDb.run(sql`
|
||||
INSERT OR IGNORE INTO user (id, name, email, email_verified, created_at, updated_at, is_anonymous)
|
||||
VALUES (${userId}, 'System User', ${userId + '@local'}, 0, ${Date.now()}, ${Date.now()}, 0)
|
||||
`);
|
||||
}
|
||||
seededUserIds.add(userId);
|
||||
} catch {
|
||||
// Silently ignore – the user table may not exist yet on first boot before migrations run.
|
||||
}
|
||||
}
|
||||
|
||||
function getDrizzleDB() {
|
||||
if (dbInstance) return dbInstance;
|
||||
|
||||
if (process.env.POSTGRES_URL) {
|
||||
dbIsPostgres = !!process.env.POSTGRES_URL;
|
||||
|
||||
if (dbIsPostgres) {
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.POSTGRES_URL,
|
||||
});
|
||||
|
|
@ -30,6 +71,8 @@ function getDrizzleDB() {
|
|||
dbInstance = drizzleSqlite(sqlite, { schema: { ...schema, ...authSchemaSqlite } });
|
||||
}
|
||||
|
||||
ensureSystemUserExists(UNCLAIMED_USER_ID);
|
||||
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
|
|
@ -44,3 +87,4 @@ export const db: any = new Proxy({} as any, {
|
|||
return typeof value === 'function' ? value.bind(instance) : value;
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { pgTable, text, integer, real, timestamp, date, bigint, primaryKey, index, jsonb } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, text, integer, real, timestamp, date, bigint, primaryKey, index, jsonb, foreignKey } from 'drizzle-orm/pg-core';
|
||||
import { user } from './schema_auth_postgres';
|
||||
|
||||
export const documents = pgTable('documents', {
|
||||
|
|
@ -40,6 +40,10 @@ export const audiobookChapters = pgTable('audiobook_chapters', {
|
|||
format: text('format').notNull(), // mp3, m4b
|
||||
}, (table) => ({
|
||||
pk: primaryKey({ columns: [table.id, table.userId] }),
|
||||
bookFk: foreignKey({
|
||||
columns: [table.bookId, table.userId],
|
||||
foreignColumns: [audiobooks.id, audiobooks.userId],
|
||||
}).onDelete('cascade'),
|
||||
}));
|
||||
|
||||
// Auth tables (user, session, account, verification) are managed by Better Auth.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { sqliteTable, text, integer, real, primaryKey, index } from 'drizzle-orm/sqlite-core';
|
||||
import { sqliteTable, text, integer, real, primaryKey, index, foreignKey } from 'drizzle-orm/sqlite-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { user } from './schema_auth_sqlite';
|
||||
|
||||
|
|
@ -41,6 +41,10 @@ export const audiobookChapters = sqliteTable('audiobook_chapters', {
|
|||
format: text('format').notNull(), // mp3, m4b
|
||||
}, (table) => ({
|
||||
pk: primaryKey({ columns: [table.id, table.userId] }),
|
||||
bookFk: foreignKey({
|
||||
columns: [table.bookId, table.userId],
|
||||
foreignColumns: [audiobooks.id, audiobooks.userId],
|
||||
}).onDelete('cascade'),
|
||||
}));
|
||||
|
||||
// Auth tables (user, session, account, verification) are managed by Better Auth.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { NextRequest } from 'next/server';
|
|||
import { db } from "@/db";
|
||||
import { rateLimiter } from "@/lib/server/rate-limiter";
|
||||
import { isAuthEnabled, isAnonymousAuthSessionsEnabled } from "@/lib/server/auth-config";
|
||||
import { deleteUserStorageData } from "@/lib/server/user-data-cleanup";
|
||||
import * as authSchemaSqlite from "@/db/schema_auth_sqlite";
|
||||
import * as authSchemaPostgres from "@/db/schema_auth_postgres";
|
||||
import {
|
||||
|
|
@ -77,6 +78,15 @@ const createAuth = () => betterAuth({
|
|||
user: {
|
||||
deleteUser: {
|
||||
enabled: true,
|
||||
beforeDelete: async (user) => {
|
||||
try {
|
||||
await deleteUserStorageData(user.id, null);
|
||||
} catch (error) {
|
||||
console.error('[auth] Failed to clean up user storage before deletion:', error);
|
||||
// Don't throw – allow the user deletion to proceed even if S3 cleanup fails.
|
||||
// Orphaned blobs are preferable to a blocked account deletion.
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
rateLimit: {
|
||||
|
|
@ -103,76 +113,76 @@ const createAuth = () => betterAuth({
|
|||
nextCookies(), // Enable Next.js cookie handling
|
||||
...(isAnonymousAuthSessionsEnabled()
|
||||
? [
|
||||
anonymous({
|
||||
onLinkAccount: async ({ anonymousUser, newUser }) => {
|
||||
anonymous({
|
||||
onLinkAccount: async ({ anonymousUser, newUser }) => {
|
||||
try {
|
||||
// Log when anonymous user links to a real account
|
||||
console.log("Anonymous user linked to account:", {
|
||||
anonymousUserId: anonymousUser.user.id,
|
||||
newUserId: newUser.user.id,
|
||||
newUserEmail: newUser.user.email,
|
||||
});
|
||||
|
||||
// Transfer rate limiting data (TTS char counts) from anonymous user to authenticated user
|
||||
try {
|
||||
// Log when anonymous user links to a real account
|
||||
console.log("Anonymous user linked to account:", {
|
||||
anonymousUserId: anonymousUser.user.id,
|
||||
newUserId: newUser.user.id,
|
||||
newUserEmail: newUser.user.email,
|
||||
});
|
||||
|
||||
// Transfer rate limiting data (TTS char counts) from anonymous user to authenticated user
|
||||
try {
|
||||
await rateLimiter.transferAnonymousUsage(anonymousUser.user.id, newUser.user.id);
|
||||
console.log(`Successfully transferred rate limit data from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
|
||||
} catch (error) {
|
||||
console.error("Error transferring rate limit data during account linking:", error);
|
||||
// Don't throw here to prevent blocking the account linking process
|
||||
}
|
||||
|
||||
// Transfer audiobooks from anonymous user to new authenticated user
|
||||
try {
|
||||
const transferred = await transferUserAudiobooks(anonymousUser.user.id, newUser.user.id);
|
||||
if (transferred > 0) {
|
||||
console.log(`Successfully transferred ${transferred} audiobook(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error transferring audiobooks during account linking:", error);
|
||||
// Don't throw here to prevent blocking the account linking process
|
||||
}
|
||||
|
||||
// Transfer documents from anonymous user to new authenticated user
|
||||
try {
|
||||
const transferred = await transferUserDocuments(anonymousUser.user.id, newUser.user.id);
|
||||
if (transferred > 0) {
|
||||
console.log(`Successfully transferred ${transferred} document(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error transferring documents during account linking:", error);
|
||||
// Don't throw here to prevent blocking the account linking process
|
||||
}
|
||||
|
||||
// Transfer preferences from anonymous user to new authenticated user
|
||||
try {
|
||||
const transferred = await transferUserPreferences(anonymousUser.user.id, newUser.user.id);
|
||||
if (transferred > 0) {
|
||||
console.log(`Successfully transferred preferences from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error transferring preferences during account linking:", error);
|
||||
// Don't throw here to prevent blocking the account linking process
|
||||
}
|
||||
|
||||
// Transfer reading progress from anonymous user to new authenticated user
|
||||
try {
|
||||
const transferred = await transferUserProgress(anonymousUser.user.id, newUser.user.id);
|
||||
if (transferred > 0) {
|
||||
console.log(`Successfully transferred ${transferred} progress row(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error transferring reading progress during account linking:", error);
|
||||
// Don't throw here to prevent blocking the account linking process
|
||||
}
|
||||
await rateLimiter.transferAnonymousUsage(anonymousUser.user.id, newUser.user.id);
|
||||
console.log(`Successfully transferred rate limit data from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
|
||||
} catch (error) {
|
||||
console.error("Error in onLinkAccount callback:", error);
|
||||
console.error("Error transferring rate limit data during account linking:", error);
|
||||
// Don't throw here to prevent blocking the account linking process
|
||||
}
|
||||
// Note: Anonymous user will be automatically deleted after this callback completes
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
// Transfer audiobooks from anonymous user to new authenticated user
|
||||
try {
|
||||
const transferred = await transferUserAudiobooks(anonymousUser.user.id, newUser.user.id);
|
||||
if (transferred > 0) {
|
||||
console.log(`Successfully transferred ${transferred} audiobook(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error transferring audiobooks during account linking:", error);
|
||||
// Don't throw here to prevent blocking the account linking process
|
||||
}
|
||||
|
||||
// Transfer documents from anonymous user to new authenticated user
|
||||
try {
|
||||
const transferred = await transferUserDocuments(anonymousUser.user.id, newUser.user.id);
|
||||
if (transferred > 0) {
|
||||
console.log(`Successfully transferred ${transferred} document(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error transferring documents during account linking:", error);
|
||||
// Don't throw here to prevent blocking the account linking process
|
||||
}
|
||||
|
||||
// Transfer preferences from anonymous user to new authenticated user
|
||||
try {
|
||||
const transferred = await transferUserPreferences(anonymousUser.user.id, newUser.user.id);
|
||||
if (transferred > 0) {
|
||||
console.log(`Successfully transferred preferences from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error transferring preferences during account linking:", error);
|
||||
// Don't throw here to prevent blocking the account linking process
|
||||
}
|
||||
|
||||
// Transfer reading progress from anonymous user to new authenticated user
|
||||
try {
|
||||
const transferred = await transferUserProgress(anonymousUser.user.id, newUser.user.id);
|
||||
if (transferred > 0) {
|
||||
console.log(`Successfully transferred ${transferred} progress row(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error transferring reading progress during account linking:", error);
|
||||
// Don't throw here to prevent blocking the account linking process
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in onLinkAccount callback:", error);
|
||||
// Don't throw here to prevent blocking the account linking process
|
||||
}
|
||||
// Note: Anonymous user will be automatically deleted after this callback completes
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import path from 'path';
|
||||
import { UNCLAIMED_USER_ID } from '@/lib/server/docstore';
|
||||
import { ensureSystemUserExists } from '@/db';
|
||||
|
||||
const TEST_NAMESPACE_HEADER = 'x-openreader-test-namespace';
|
||||
const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
|
||||
|
|
@ -24,7 +25,8 @@ export function applyOpenReaderTestNamespacePath(baseDir: string, namespace: str
|
|||
}
|
||||
|
||||
export function getUnclaimedUserIdForNamespace(namespace: string | null): string {
|
||||
if (!namespace) return UNCLAIMED_USER_ID;
|
||||
return `${UNCLAIMED_USER_ID}::${namespace}`;
|
||||
const userId = !namespace ? UNCLAIMED_USER_ID : `${UNCLAIMED_USER_ID}::${namespace}`;
|
||||
ensureSystemUserExists(userId);
|
||||
return userId;
|
||||
}
|
||||
|
||||
|
|
|
|||
83
src/lib/server/user-data-cleanup.ts
Normal file
83
src/lib/server/user-data-cleanup.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* Cleans up all S3 storage artifacts belonging to a user.
|
||||
* Called from Better Auth's `beforeDelete` hook so that blobs are removed
|
||||
* before the DB cascade wipes the metadata rows we query against.
|
||||
*/
|
||||
|
||||
import { db } from '@/db';
|
||||
import { documents, audiobooks } from '@/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { isS3Configured } from '@/lib/server/s3';
|
||||
import { deleteDocumentBlob } from '@/lib/server/documents-blobstore';
|
||||
import { deleteDocumentPreviewArtifacts } from '@/lib/server/document-previews-blobstore';
|
||||
import { audiobookPrefix, deleteAudiobookPrefix } from '@/lib/server/audiobooks-blobstore';
|
||||
|
||||
type DocumentRow = { id: string };
|
||||
type AudiobookRow = { id: string };
|
||||
|
||||
/**
|
||||
* Delete all S3 blobs owned by `userId`.
|
||||
*
|
||||
* This covers:
|
||||
* - Document file blobs
|
||||
* - Document preview images
|
||||
* - Audiobook audio files (chapter mp3s, metadata json, etc.)
|
||||
*
|
||||
* Each item is cleaned up independently; a failure on one does not block the rest.
|
||||
*/
|
||||
export async function deleteUserStorageData(
|
||||
userId: string,
|
||||
namespace: string | null,
|
||||
): Promise<void> {
|
||||
if (!isS3Configured()) return;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const database = db as any;
|
||||
|
||||
// --- Documents & previews ---
|
||||
const userDocs: DocumentRow[] = await database
|
||||
.select({ id: documents.id })
|
||||
.from(documents)
|
||||
.where(eq(documents.userId, userId));
|
||||
|
||||
let docsDeleted = 0;
|
||||
for (const doc of userDocs) {
|
||||
try {
|
||||
await deleteDocumentBlob(doc.id, namespace);
|
||||
docsDeleted++;
|
||||
} catch (error) {
|
||||
console.error(`[user-data-cleanup] Failed to delete document blob ${doc.id}:`, error);
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteDocumentPreviewArtifacts(doc.id, namespace);
|
||||
} catch (error) {
|
||||
console.error(`[user-data-cleanup] Failed to delete preview for ${doc.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Audiobooks ---
|
||||
const userBooks: AudiobookRow[] = await database
|
||||
.select({ id: audiobooks.id })
|
||||
.from(audiobooks)
|
||||
.where(eq(audiobooks.userId, userId));
|
||||
|
||||
let booksDeleted = 0;
|
||||
for (const book of userBooks) {
|
||||
try {
|
||||
const prefix = audiobookPrefix(book.id, userId, namespace);
|
||||
await deleteAudiobookPrefix(prefix);
|
||||
booksDeleted++;
|
||||
} catch (error) {
|
||||
console.error(`[user-data-cleanup] Failed to delete audiobook blobs ${book.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (docsDeleted > 0 || booksDeleted > 0) {
|
||||
console.log(
|
||||
`[user-data-cleanup] Cleaned up S3 data for user ${userId}: ` +
|
||||
`${docsDeleted}/${userDocs.length} document(s), ` +
|
||||
`${booksDeleted}/${userBooks.length} audiobook(s)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue