import fs from "node:fs/promises"; import path from "node:path"; import { dbGlobal } from "drizzle-pkg/lib/db"; import { users } from "drizzle-pkg/lib/schema/auth"; import { userConfigs } from "drizzle-pkg/lib/schema/config"; import { mediaAssets, mediaRefs, postComments, posts, timelineEvents } from "drizzle-pkg/lib/schema/content"; import { and, eq, inArray, lte, or } from "drizzle-orm"; import { MEDIA_REF_OWNER_POST } from "../../constants/media-refs"; import type { ExportMaskPolicy } from "../../constants/export"; import { sanitizeUserForExport } from "../../utils/export-mask"; export type BuildExportDataResult = { dataFiles: Array<{ file: string; rowCount: number }>; totalRows: number; }; async function writeDataFile(baseDir: string, fileName: string, payload: unknown): Promise<{ file: string; rowCount: number }> { const file = `data/${fileName}`; const fullPath = path.resolve(baseDir, fileName); await fs.writeFile(fullPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); const rowCount = Array.isArray(payload) ? payload.length : payload ? 1 : 0; return { file, rowCount }; } export async function buildExportDataJson(params: { userId: number; maskPolicy: ExportMaskPolicy; cutoffAt: Date; outputDataDir: string; }): Promise { await fs.mkdir(params.outputDataDir, { recursive: true }); const [userRow] = await dbGlobal.select().from(users).where(eq(users.id, params.userId)).limit(1); if (!userRow) { throw new Error(`user not found: ${params.userId}`); } const sanitizedUser = sanitizeUserForExport(userRow as unknown as Record, params.maskPolicy); const [postRows, timelineRows, mediaRows, configRows] = await Promise.all([ dbGlobal .select() .from(posts) .where(and(eq(posts.userId, params.userId), lte(posts.createdAt, params.cutoffAt))), dbGlobal .select() .from(timelineEvents) .where(and(eq(timelineEvents.userId, params.userId), lte(timelineEvents.createdAt, params.cutoffAt))), dbGlobal .select() .from(mediaAssets) .where(and(eq(mediaAssets.userId, params.userId), lte(mediaAssets.createdAt, params.cutoffAt))), dbGlobal .select() .from(userConfigs) .where(and(eq(userConfigs.userId, params.userId), lte(userConfigs.updatedAt, params.cutoffAt))), ]); const postIds = postRows.map((p) => p.id); const commentCondition = postIds.length === 0 ? and(lte(postComments.createdAt, params.cutoffAt), eq(postComments.authorUserId, params.userId)) : and( lte(postComments.createdAt, params.cutoffAt), or(eq(postComments.authorUserId, params.userId), inArray(postComments.postId, postIds)), ); const commentRowsRaw = await dbGlobal.select().from(postComments).where(commentCondition); const mediaRefRows = postIds.length === 0 ? [] : await dbGlobal .select() .from(mediaRefs) .where(and(eq(mediaRefs.ownerType, MEDIA_REF_OWNER_POST), inArray(mediaRefs.ownerId, postIds))); const commentRows = commentRowsRaw.map((row) => params.maskPolicy === "masked" ? sanitizeUserForExport(row as unknown as Record, params.maskPolicy) : row, ); const configRowsMasked = configRows.map((row) => params.maskPolicy === "masked" ? sanitizeUserForExport(row as unknown as Record, params.maskPolicy) : row, ); const dataFiles = await Promise.all([ writeDataFile(params.outputDataDir, "user.json", sanitizedUser), writeDataFile(params.outputDataDir, "posts.json", postRows), writeDataFile(params.outputDataDir, "timeline.json", timelineRows), writeDataFile(params.outputDataDir, "comments.json", commentRows), writeDataFile(params.outputDataDir, "media-assets.json", mediaRows), writeDataFile(params.outputDataDir, "media-refs.json", mediaRefRows), writeDataFile(params.outputDataDir, "user-configs.json", configRowsMasked), ]); const totalRows = dataFiles.reduce((sum, item) => sum + item.rowCount, 0); return { dataFiles, totalRows }; } export async function listExportUserMediaAssets(params: { userId: number; cutoffAt: Date; }): Promise> { const rows = await dbGlobal .select({ storageKey: mediaAssets.storageKey, }) .from(mediaAssets) .where(and(eq(mediaAssets.userId, params.userId), lte(mediaAssets.createdAt, params.cutoffAt))); return rows; }