mirror of
https://github.com/willchen96/mike.git
synced 2026-07-24 23:41:04 +02:00
feat: implement multi-factor authentication (MFA) setup and verification flow
- Add SecurityPage component for managing MFA settings, including enrollment and verification. - Create MfaLoginGate to handle MFA verification state during login. - Develop MfaVerificationPopup for user input of verification codes. - Implement VerifyMfaPage for the MFA verification process after login. - Introduce reusable VerificationCodeInput component for entering verification codes. - Integrate Supabase MFA API for managing factors and verification. - Add loading states and error handling for a better user experience.
This commit is contained in:
parent
15c96b0dd4
commit
3a10943200
32 changed files with 3704 additions and 311 deletions
|
|
@ -37,6 +37,7 @@ import {
|
|||
type LlmMessage,
|
||||
type OpenAIToolSchema,
|
||||
} from "./llm";
|
||||
import { safeErrorMessage } from "./safeError";
|
||||
|
||||
const STANDARD_FONT_DATA_URL = (() => {
|
||||
try {
|
||||
|
|
@ -4172,8 +4173,7 @@ export async function runLLMStream(params: {
|
|||
throw new AssistantStreamAbortError(fullText, events);
|
||||
}
|
||||
flushPartialTurn();
|
||||
const message =
|
||||
err instanceof Error && err.message ? err.message : "Stream error";
|
||||
const message = safeErrorMessage(err, "Stream error");
|
||||
events.push({ type: "error", message });
|
||||
throw new AssistantStreamError(message, fullText, events);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,81 @@
|
|||
import JSZip from "jszip";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
let _convert:
|
||||
| ((buf: Buffer, ext: string, filter: undefined) => Promise<Buffer>)
|
||||
| null = null;
|
||||
let _sofficeBinaryPaths: string[] | null = null;
|
||||
|
||||
function executablePath(filePath: string) {
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSofficeBinaryPaths(): string[] {
|
||||
if (_sofficeBinaryPaths) return _sofficeBinaryPaths;
|
||||
|
||||
const candidates = new Set<string>();
|
||||
for (const envName of [
|
||||
"SOFFICE_BINARY_PATH",
|
||||
"LIBREOFFICE_BINARY_PATH",
|
||||
"LIBRE_OFFICE_EXE",
|
||||
]) {
|
||||
const value = process.env[envName]?.trim();
|
||||
if (value) candidates.add(value);
|
||||
}
|
||||
|
||||
const pathDirs = (process.env.PATH ?? "")
|
||||
.split(path.delimiter)
|
||||
.filter(Boolean);
|
||||
for (const dir of pathDirs) {
|
||||
candidates.add(path.join(dir, "soffice"));
|
||||
candidates.add(path.join(dir, "libreoffice"));
|
||||
}
|
||||
|
||||
for (const filePath of [
|
||||
"/usr/bin/libreoffice",
|
||||
"/usr/bin/soffice",
|
||||
"/snap/bin/libreoffice",
|
||||
"/opt/libreoffice/program/soffice",
|
||||
"/opt/libreoffice7.6/program/soffice",
|
||||
]) {
|
||||
candidates.add(filePath);
|
||||
}
|
||||
|
||||
_sofficeBinaryPaths = [...candidates].filter(executablePath);
|
||||
return _sofficeBinaryPaths;
|
||||
}
|
||||
|
||||
async function getConvert() {
|
||||
if (!_convert) {
|
||||
const libre = await import("libreoffice-convert");
|
||||
const convert = libre.default.convert.bind(libre.default) as (
|
||||
const convertWithOptions = libre.default.convertWithOptions.bind(
|
||||
libre.default,
|
||||
) as (
|
||||
buf: Buffer,
|
||||
ext: string,
|
||||
filter: undefined,
|
||||
options: { sofficeBinaryPaths?: string[] },
|
||||
callback?: (err: Error | null, result: Buffer) => void,
|
||||
) => Promise<Buffer> | void;
|
||||
_convert = (buf, ext, filter) =>
|
||||
new Promise<Buffer>((resolve, reject) => {
|
||||
try {
|
||||
const maybePromise = convert(buf, ext, filter, (err, result) => {
|
||||
if (err) reject(err);
|
||||
else resolve(result);
|
||||
});
|
||||
const maybePromise = convertWithOptions(
|
||||
buf,
|
||||
ext,
|
||||
filter,
|
||||
{ sofficeBinaryPaths: resolveSofficeBinaryPaths() },
|
||||
(err, result) => {
|
||||
if (err) reject(err);
|
||||
else resolve(result);
|
||||
},
|
||||
);
|
||||
if (maybePromise && typeof maybePromise.then === "function") {
|
||||
maybePromise.then(resolve, reject);
|
||||
}
|
||||
|
|
@ -67,6 +123,11 @@ export async function normalizeDocxZipPaths(buffer: Buffer): Promise<Buffer> {
|
|||
* Throws if LibreOffice is not installed or conversion fails.
|
||||
*/
|
||||
export async function docxToPdf(buffer: Buffer): Promise<Buffer> {
|
||||
if (resolveSofficeBinaryPaths().length === 0) {
|
||||
throw new Error(
|
||||
"LibreOffice/soffice binary was not found. Ensure Railway uses backend/nixpacks.toml or set SOFFICE_BINARY_PATH/LIBREOFFICE_BINARY_PATH.",
|
||||
);
|
||||
}
|
||||
const convert = await getConvert();
|
||||
const normalized = await normalizeDocxZipPaths(buffer);
|
||||
return convert(normalized, ".pdf", undefined);
|
||||
|
|
|
|||
59
backend/src/lib/safeError.ts
Normal file
59
backend/src/lib/safeError.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
const SECRET_CONTEXT_PATTERNS = [
|
||||
/(Incorrect API key provided:\s*)([^.\s]+)(\.?)/gi,
|
||||
/(api[_ -]?key|x-api-key|token|secret|authorization|bearer)\s*(?:provided\s*)?(?:is|:|=)\s*["']?([A-Za-z0-9._\-]{6,})["']?/gi,
|
||||
];
|
||||
|
||||
const PROVIDER_KEY_PATTERNS = [
|
||||
/\bsk-[A-Za-z0-9_\-]{12,}\b/g,
|
||||
/\bsk-ant-[A-Za-z0-9_\-]{12,}\b/g,
|
||||
/\bsk-or-[A-Za-z0-9_\-]{12,}\b/g,
|
||||
/\bAIza[A-Za-z0-9_\-]{20,}\b/g,
|
||||
];
|
||||
|
||||
export function redactSensitiveText(value: string): string {
|
||||
let redacted = value;
|
||||
for (const pattern of SECRET_CONTEXT_PATTERNS) {
|
||||
redacted = redacted.replace(pattern, (match, ...groups: string[]) => {
|
||||
if (match.toLowerCase().startsWith("incorrect api key provided:")) {
|
||||
return `${groups[0]}[redacted]${groups[2] ?? ""}`;
|
||||
}
|
||||
const secret = groups[1];
|
||||
return secret ? match.replace(secret, "[redacted]") : match;
|
||||
});
|
||||
}
|
||||
for (const pattern of PROVIDER_KEY_PATTERNS) {
|
||||
redacted = redacted.replace(pattern, "[redacted]");
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
export function safeErrorMessage(
|
||||
error: unknown,
|
||||
fallback = "Unexpected error",
|
||||
): string {
|
||||
const message =
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: fallback;
|
||||
return redactSensitiveText(message);
|
||||
}
|
||||
|
||||
export function safeErrorLog(error: unknown): {
|
||||
name: string | null;
|
||||
message: string;
|
||||
stack?: string;
|
||||
} {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: error.name || null,
|
||||
message: redactSensitiveText(error.message || "Unexpected error"),
|
||||
stack: error.stack ? redactSensitiveText(error.stack) : undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: null,
|
||||
message: safeErrorMessage(error),
|
||||
};
|
||||
}
|
||||
339
backend/src/lib/userDataCleanup.ts
Normal file
339
backend/src/lib/userDataCleanup.ts
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
import { createServerSupabase } from "./supabase";
|
||||
import { deleteFile, listFiles } from "./storage";
|
||||
|
||||
type Db = ReturnType<typeof createServerSupabase>;
|
||||
|
||||
const DELETE_BATCH_SIZE = 500;
|
||||
|
||||
function uniqueStrings(values: Array<string | null | undefined>): string[] {
|
||||
return [...new Set(values.filter((value): value is string => !!value))];
|
||||
}
|
||||
|
||||
function chunks<T>(values: T[], size = DELETE_BATCH_SIZE): T[][] {
|
||||
const result: T[][] = [];
|
||||
for (let i = 0; i < values.length; i += size) {
|
||||
result.push(values.slice(i, i + size));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function throwIfError<T extends { message?: string } | null>(
|
||||
error: T,
|
||||
context: string,
|
||||
) {
|
||||
if (error) throw new Error(`${context}: ${error.message ?? "unknown error"}`);
|
||||
}
|
||||
|
||||
async function deleteByIds(db: Db, table: string, ids: string[]) {
|
||||
for (const batch of chunks(ids)) {
|
||||
const { error } = await (db as any).from(table).delete().in("id", batch);
|
||||
await throwIfError(error, `Failed to delete ${table}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteWhereIn(
|
||||
db: Db,
|
||||
table: string,
|
||||
column: string,
|
||||
values: string[],
|
||||
) {
|
||||
for (const batch of chunks(values)) {
|
||||
const { error } = await (db as any)
|
||||
.from(table)
|
||||
.delete()
|
||||
.in(column, batch);
|
||||
await throwIfError(error, `Failed to delete ${table}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function getOwnedProjectIds(db: Db, userId: string): Promise<string[]> {
|
||||
const { data, error } = await db
|
||||
.from("projects")
|
||||
.select("id")
|
||||
.eq("user_id", userId);
|
||||
await throwIfError(error, "Failed to load user projects");
|
||||
return uniqueStrings((data ?? []).map((row) => row.id as string | null));
|
||||
}
|
||||
|
||||
async function getDocumentIdsForAccountDeletion(
|
||||
db: Db,
|
||||
userId: string,
|
||||
ownedProjectIds: string[],
|
||||
): Promise<string[]> {
|
||||
const [ownedDocs, projectDocs] = await Promise.all([
|
||||
db.from("documents").select("id").eq("user_id", userId),
|
||||
ownedProjectIds.length > 0
|
||||
? db.from("documents").select("id").in("project_id", ownedProjectIds)
|
||||
: Promise.resolve({ data: [], error: null }),
|
||||
]);
|
||||
|
||||
await throwIfError(ownedDocs.error, "Failed to load user documents");
|
||||
await throwIfError(projectDocs.error, "Failed to load project documents");
|
||||
|
||||
return uniqueStrings([
|
||||
...((ownedDocs.data ?? []) as { id: string | null }[]).map((row) => row.id),
|
||||
...((projectDocs.data ?? []) as { id: string | null }[]).map((row) => row.id),
|
||||
]);
|
||||
}
|
||||
|
||||
async function deleteDocumentVersionFiles(db: Db, documentIds: string[]) {
|
||||
const paths = new Set<string>();
|
||||
|
||||
for (const batch of chunks(documentIds)) {
|
||||
const { data, error } = await db
|
||||
.from("document_versions")
|
||||
.select("storage_path, pdf_storage_path")
|
||||
.in("document_id", batch);
|
||||
await throwIfError(error, "Failed to load document storage paths");
|
||||
|
||||
for (const version of data ?? []) {
|
||||
if (
|
||||
typeof version.storage_path === "string" &&
|
||||
version.storage_path.length > 0
|
||||
) {
|
||||
paths.add(version.storage_path);
|
||||
}
|
||||
if (
|
||||
typeof version.pdf_storage_path === "string" &&
|
||||
version.pdf_storage_path.length > 0
|
||||
) {
|
||||
paths.add(version.pdf_storage_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([...paths].map((path) => deleteFile(path)));
|
||||
}
|
||||
|
||||
async function deleteUserStoragePrefix(userId: string) {
|
||||
try {
|
||||
const paths = await listFiles(`documents/${userId}/`);
|
||||
await Promise.all(paths.map((path) => deleteFile(path).catch(() => {})));
|
||||
} catch {
|
||||
// Version-linked objects are deleted above. Prefix cleanup is best-effort
|
||||
// for orphaned files left behind by interrupted uploads.
|
||||
}
|
||||
}
|
||||
|
||||
async function removeEmailFromSharedWith(
|
||||
db: Db,
|
||||
table: "projects" | "tabular_reviews",
|
||||
email: string | null | undefined,
|
||||
) {
|
||||
const normalizedEmail = email?.trim().toLowerCase();
|
||||
if (!normalizedEmail) return;
|
||||
|
||||
const { data, error } = await db
|
||||
.from(table)
|
||||
.select("id, shared_with")
|
||||
.filter("shared_with", "cs", JSON.stringify([normalizedEmail]));
|
||||
await throwIfError(error, `Failed to load shared ${table}`);
|
||||
|
||||
const updates = (data ?? [])
|
||||
.map((row) => {
|
||||
const sharedWith = Array.isArray(row.shared_with)
|
||||
? row.shared_with.filter(
|
||||
(value) =>
|
||||
typeof value !== "string" ||
|
||||
value.trim().toLowerCase() !== normalizedEmail,
|
||||
)
|
||||
: [];
|
||||
return { id: row.id as string, sharedWith };
|
||||
})
|
||||
.filter((row) => row.id);
|
||||
|
||||
await Promise.all(
|
||||
updates.map(async ({ id, sharedWith }) => {
|
||||
const { error: updateError } = await db
|
||||
.from(table)
|
||||
.update({ shared_with: sharedWith })
|
||||
.eq("id", id);
|
||||
await throwIfError(updateError, `Failed to update shared ${table}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteAllUserChats(db: Db, userId: string) {
|
||||
const [assistantChats, tabularChats] = await Promise.all([
|
||||
db.from("chats").delete().eq("user_id", userId),
|
||||
db.from("tabular_review_chats").delete().eq("user_id", userId),
|
||||
]);
|
||||
|
||||
await throwIfError(assistantChats.error, "Failed to delete assistant chats");
|
||||
await throwIfError(tabularChats.error, "Failed to delete tabular chats");
|
||||
}
|
||||
|
||||
export async function deleteAllUserTabularReviews(db: Db, userId: string) {
|
||||
const { data: reviews, error: reviewsError } = await db
|
||||
.from("tabular_reviews")
|
||||
.select("id")
|
||||
.eq("user_id", userId);
|
||||
await throwIfError(reviewsError, "Failed to load tabular reviews");
|
||||
|
||||
const reviewIds = uniqueStrings(
|
||||
((reviews ?? []) as { id: string | null }[]).map((row) => row.id),
|
||||
);
|
||||
if (reviewIds.length === 0) return 0;
|
||||
|
||||
const { data: reviewChats, error: reviewChatsError } = await db
|
||||
.from("tabular_review_chats")
|
||||
.select("id")
|
||||
.in("review_id", reviewIds);
|
||||
await throwIfError(reviewChatsError, "Failed to load tabular review chats");
|
||||
|
||||
const reviewChatIds = uniqueStrings(
|
||||
((reviewChats ?? []) as { id: string | null }[]).map((row) => row.id),
|
||||
);
|
||||
|
||||
await deleteWhereIn(
|
||||
db,
|
||||
"tabular_review_chat_messages",
|
||||
"chat_id",
|
||||
reviewChatIds,
|
||||
);
|
||||
await deleteWhereIn(db, "tabular_review_chats", "review_id", reviewIds);
|
||||
await deleteWhereIn(db, "tabular_cells", "review_id", reviewIds);
|
||||
await deleteByIds(db, "tabular_reviews", reviewIds);
|
||||
|
||||
return reviewIds.length;
|
||||
}
|
||||
|
||||
export async function deleteUserProjects(
|
||||
db: Db,
|
||||
userId: string,
|
||||
projectIds?: string[],
|
||||
) {
|
||||
const requestedProjectIds = projectIds
|
||||
? uniqueStrings(projectIds)
|
||||
: undefined;
|
||||
if (requestedProjectIds && requestedProjectIds.length === 0) return 0;
|
||||
|
||||
let query = db.from("projects").select("id").eq("user_id", userId);
|
||||
if (requestedProjectIds) query = query.in("id", requestedProjectIds);
|
||||
|
||||
const { data: projects, error: projectsError } = await query;
|
||||
await throwIfError(projectsError, "Failed to load user projects");
|
||||
|
||||
const ownedProjectIds = uniqueStrings(
|
||||
((projects ?? []) as { id: string | null }[]).map((row) => row.id),
|
||||
);
|
||||
if (ownedProjectIds.length === 0) return 0;
|
||||
|
||||
const [projectDocs, projectChats, projectReviews, projectFolders] =
|
||||
await Promise.all([
|
||||
db.from("documents").select("id").in("project_id", ownedProjectIds),
|
||||
db.from("chats").select("id").in("project_id", ownedProjectIds),
|
||||
db
|
||||
.from("tabular_reviews")
|
||||
.select("id")
|
||||
.in("project_id", ownedProjectIds),
|
||||
db
|
||||
.from("project_subfolders")
|
||||
.select("id")
|
||||
.in("project_id", ownedProjectIds),
|
||||
]);
|
||||
|
||||
await throwIfError(projectDocs.error, "Failed to load project documents");
|
||||
await throwIfError(projectChats.error, "Failed to load project chats");
|
||||
await throwIfError(
|
||||
projectReviews.error,
|
||||
"Failed to load project tabular reviews",
|
||||
);
|
||||
await throwIfError(projectFolders.error, "Failed to load project folders");
|
||||
|
||||
const documentIds = uniqueStrings(
|
||||
((projectDocs.data ?? []) as { id: string | null }[]).map(
|
||||
(row) => row.id,
|
||||
),
|
||||
);
|
||||
const chatIds = uniqueStrings(
|
||||
((projectChats.data ?? []) as { id: string | null }[]).map(
|
||||
(row) => row.id,
|
||||
),
|
||||
);
|
||||
const reviewIds = uniqueStrings(
|
||||
((projectReviews.data ?? []) as { id: string | null }[]).map(
|
||||
(row) => row.id,
|
||||
),
|
||||
);
|
||||
const folderIds = uniqueStrings(
|
||||
((projectFolders.data ?? []) as { id: string | null }[]).map(
|
||||
(row) => row.id,
|
||||
),
|
||||
);
|
||||
|
||||
const { data: reviewChats, error: reviewChatsError } =
|
||||
reviewIds.length > 0
|
||||
? await db
|
||||
.from("tabular_review_chats")
|
||||
.select("id")
|
||||
.in("review_id", reviewIds)
|
||||
: { data: [], error: null };
|
||||
await throwIfError(reviewChatsError, "Failed to load project review chats");
|
||||
|
||||
const reviewChatIds = uniqueStrings(
|
||||
((reviewChats ?? []) as { id: string | null }[]).map((row) => row.id),
|
||||
);
|
||||
|
||||
await deleteDocumentVersionFiles(db, documentIds);
|
||||
await deleteWhereIn(
|
||||
db,
|
||||
"tabular_review_chat_messages",
|
||||
"chat_id",
|
||||
reviewChatIds,
|
||||
);
|
||||
await deleteWhereIn(db, "tabular_review_chats", "review_id", reviewIds);
|
||||
await deleteWhereIn(db, "tabular_cells", "review_id", reviewIds);
|
||||
await deleteByIds(db, "tabular_reviews", reviewIds);
|
||||
await deleteWhereIn(db, "chat_messages", "chat_id", chatIds);
|
||||
await deleteByIds(db, "chats", chatIds);
|
||||
await deleteByIds(db, "documents", documentIds);
|
||||
await deleteByIds(db, "project_subfolders", folderIds);
|
||||
await deleteByIds(db, "projects", ownedProjectIds);
|
||||
|
||||
return ownedProjectIds.length;
|
||||
}
|
||||
|
||||
export async function deleteUserAccountData(
|
||||
db: Db,
|
||||
userId: string,
|
||||
userEmail?: string | null,
|
||||
) {
|
||||
const ownedProjectIds = await getOwnedProjectIds(db, userId);
|
||||
const documentIds = await getDocumentIdsForAccountDeletion(
|
||||
db,
|
||||
userId,
|
||||
ownedProjectIds,
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
removeEmailFromSharedWith(db, "projects", userEmail),
|
||||
removeEmailFromSharedWith(db, "tabular_reviews", userEmail),
|
||||
deleteDocumentVersionFiles(db, documentIds),
|
||||
deleteUserStoragePrefix(userId),
|
||||
]);
|
||||
|
||||
await deleteByIds(db, "documents", documentIds);
|
||||
|
||||
const deletions = [
|
||||
db.from("tabular_review_chats").delete().eq("user_id", userId),
|
||||
db.from("tabular_reviews").delete().eq("user_id", userId),
|
||||
db.from("chats").delete().eq("user_id", userId),
|
||||
db.from("project_subfolders").delete().eq("user_id", userId),
|
||||
db.from("hidden_workflows").delete().eq("user_id", userId),
|
||||
db.from("workflow_shares").delete().eq("shared_by_user_id", userId),
|
||||
userEmail
|
||||
? db
|
||||
.from("workflow_shares")
|
||||
.delete()
|
||||
.eq("shared_with_email", userEmail.trim().toLowerCase())
|
||||
: Promise.resolve({ error: null }),
|
||||
db.from("workflows").delete().eq("user_id", userId),
|
||||
db.from("projects").delete().eq("user_id", userId),
|
||||
];
|
||||
|
||||
const results = await Promise.all(deletions);
|
||||
for (const result of results) {
|
||||
await throwIfError(result.error, "Failed to delete account data");
|
||||
}
|
||||
}
|
||||
278
backend/src/lib/userDataExport.ts
Normal file
278
backend/src/lib/userDataExport.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
import { createServerSupabase } from "./supabase";
|
||||
|
||||
type Db = ReturnType<typeof createServerSupabase>;
|
||||
|
||||
const PAGE_SIZE = 1000;
|
||||
|
||||
function nowStamp() {
|
||||
return new Date().toISOString().replace(/[:.]/g, "-");
|
||||
}
|
||||
|
||||
export function userExportFilename(
|
||||
kind: "account" | "chats" | "tabular-reviews",
|
||||
userId: string,
|
||||
) {
|
||||
return `mike-${kind}-export-${userId.slice(0, 8)}-${nowStamp()}.json`;
|
||||
}
|
||||
|
||||
function uniqueStrings(values: Array<string | null | undefined>): string[] {
|
||||
return [...new Set(values.filter((value): value is string => !!value))];
|
||||
}
|
||||
|
||||
async function throwIfError<T extends { message?: string } | null>(
|
||||
error: T,
|
||||
context: string,
|
||||
) {
|
||||
if (error) throw new Error(`${context}: ${error.message ?? "unknown error"}`);
|
||||
}
|
||||
|
||||
async function selectAll(
|
||||
db: Db,
|
||||
table: string,
|
||||
configure: (query: any) => any,
|
||||
columns = "*",
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
|
||||
for (let from = 0; ; from += PAGE_SIZE) {
|
||||
const to = from + PAGE_SIZE - 1;
|
||||
const query = configure(
|
||||
(db as any)
|
||||
.from(table)
|
||||
.select(columns)
|
||||
.range(from, to),
|
||||
);
|
||||
const { data, error } = await query;
|
||||
await throwIfError(error, `Failed to export ${table}`);
|
||||
const batch = (data ?? []) as Record<string, unknown>[];
|
||||
rows.push(...batch);
|
||||
if (batch.length < PAGE_SIZE) break;
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function selectByIds(
|
||||
db: Db,
|
||||
table: string,
|
||||
column: string,
|
||||
ids: string[],
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
if (ids.length === 0) return [];
|
||||
return selectAll(db, table, (query) => query.in(column, ids));
|
||||
}
|
||||
|
||||
function idsFrom(rows: Record<string, unknown>[], column = "id"): string[] {
|
||||
return uniqueStrings(
|
||||
rows.map((row) =>
|
||||
typeof row[column] === "string" ? (row[column] as string) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadUserChats(db: Db, userId: string) {
|
||||
const chats = await selectAll(db, "chats", (query) =>
|
||||
query.eq("user_id", userId).order("created_at", { ascending: true }),
|
||||
);
|
||||
const chatIds = idsFrom(chats);
|
||||
const messages = await selectByIds(db, "chat_messages", "chat_id", chatIds);
|
||||
return { chats, messages };
|
||||
}
|
||||
|
||||
async function loadUserTabularChats(db: Db, userId: string) {
|
||||
const chats = await selectAll(db, "tabular_review_chats", (query) =>
|
||||
query.eq("user_id", userId).order("created_at", { ascending: true }),
|
||||
);
|
||||
const chatIds = idsFrom(chats);
|
||||
const messages = await selectByIds(
|
||||
db,
|
||||
"tabular_review_chat_messages",
|
||||
"chat_id",
|
||||
chatIds,
|
||||
);
|
||||
return { chats, messages };
|
||||
}
|
||||
|
||||
async function loadApiKeyStatus(db: Db, userId: string) {
|
||||
const rows = await selectAll(db, "user_api_keys", (query) =>
|
||||
query
|
||||
.eq("user_id", userId)
|
||||
.order("provider", { ascending: true }),
|
||||
"provider, created_at, updated_at",
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
provider: row.provider,
|
||||
has_key: true,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function buildUserChatsExport(
|
||||
db: Db,
|
||||
userId: string,
|
||||
userEmail?: string | null,
|
||||
) {
|
||||
const [assistant, tabular] = await Promise.all([
|
||||
loadUserChats(db, userId),
|
||||
loadUserTabularChats(db, userId),
|
||||
]);
|
||||
|
||||
return {
|
||||
exported_at: new Date().toISOString(),
|
||||
user: { id: userId, email: userEmail ?? null },
|
||||
assistant_chats: assistant,
|
||||
tabular_review_chats: tabular,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildUserTabularReviewsExport(
|
||||
db: Db,
|
||||
userId: string,
|
||||
userEmail?: string | null,
|
||||
) {
|
||||
const tabularReviews = await selectAll(db, "tabular_reviews", (query) =>
|
||||
query.eq("user_id", userId).order("created_at", { ascending: true }),
|
||||
);
|
||||
const reviewIds = idsFrom(tabularReviews);
|
||||
|
||||
const [cells, chats] = await Promise.all([
|
||||
selectByIds(db, "tabular_cells", "review_id", reviewIds),
|
||||
selectByIds(db, "tabular_review_chats", "review_id", reviewIds),
|
||||
]);
|
||||
const chatIds = idsFrom(chats);
|
||||
const messages = await selectByIds(
|
||||
db,
|
||||
"tabular_review_chat_messages",
|
||||
"chat_id",
|
||||
chatIds,
|
||||
);
|
||||
|
||||
return {
|
||||
exported_at: new Date().toISOString(),
|
||||
user: { id: userId, email: userEmail ?? null },
|
||||
tabular_reviews: tabularReviews,
|
||||
tabular_cells: cells,
|
||||
tabular_review_chats: {
|
||||
chats,
|
||||
messages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildUserAccountExport(
|
||||
db: Db,
|
||||
userId: string,
|
||||
userEmail?: string | null,
|
||||
) {
|
||||
const [
|
||||
profile,
|
||||
apiKeys,
|
||||
projects,
|
||||
standaloneDocuments,
|
||||
workflows,
|
||||
hiddenWorkflows,
|
||||
workflowSharesByUser,
|
||||
workflowSharesWithUser,
|
||||
assistantChats,
|
||||
tabularChats,
|
||||
tabularReviews,
|
||||
sharedProjects,
|
||||
sharedTabularReviews,
|
||||
] = await Promise.all([
|
||||
selectAll(db, "user_profiles", (query) => query.eq("user_id", userId)),
|
||||
loadApiKeyStatus(db, userId),
|
||||
selectAll(db, "projects", (query) =>
|
||||
query.eq("user_id", userId).order("created_at", { ascending: true }),
|
||||
),
|
||||
selectAll(db, "documents", (query) =>
|
||||
query
|
||||
.eq("user_id", userId)
|
||||
.is("project_id", null)
|
||||
.order("created_at", { ascending: true }),
|
||||
),
|
||||
selectAll(db, "workflows", (query) =>
|
||||
query.eq("user_id", userId).order("created_at", { ascending: true }),
|
||||
),
|
||||
selectAll(db, "hidden_workflows", (query) =>
|
||||
query.eq("user_id", userId).order("created_at", { ascending: true }),
|
||||
),
|
||||
selectAll(db, "workflow_shares", (query) =>
|
||||
query
|
||||
.eq("shared_by_user_id", userId)
|
||||
.order("created_at", { ascending: true }),
|
||||
),
|
||||
userEmail
|
||||
? selectAll(db, "workflow_shares", (query) =>
|
||||
query
|
||||
.eq("shared_with_email", userEmail)
|
||||
.order("created_at", { ascending: true }),
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
loadUserChats(db, userId),
|
||||
loadUserTabularChats(db, userId),
|
||||
selectAll(db, "tabular_reviews", (query) =>
|
||||
query.eq("user_id", userId).order("created_at", { ascending: true }),
|
||||
),
|
||||
userEmail
|
||||
? selectAll(db, "projects", (query) =>
|
||||
query
|
||||
.filter("shared_with", "cs", JSON.stringify([userEmail]))
|
||||
.neq("user_id", userId)
|
||||
.order("created_at", { ascending: true }),
|
||||
"id, user_id, name, cm_number, created_at, updated_at",
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
userEmail
|
||||
? selectAll(db, "tabular_reviews", (query) =>
|
||||
query
|
||||
.filter("shared_with", "cs", JSON.stringify([userEmail]))
|
||||
.neq("user_id", userId)
|
||||
.order("created_at", { ascending: true }),
|
||||
"id, user_id, project_id, title, practice, created_at, updated_at",
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const projectIds = idsFrom(projects);
|
||||
const projectDocuments = await selectByIds(
|
||||
db,
|
||||
"documents",
|
||||
"project_id",
|
||||
projectIds,
|
||||
);
|
||||
const documents = [...standaloneDocuments, ...projectDocuments];
|
||||
const documentIds = idsFrom(documents);
|
||||
const reviewIds = idsFrom(tabularReviews);
|
||||
|
||||
const [folders, versions, edits, tabularCells] = await Promise.all([
|
||||
selectByIds(db, "project_subfolders", "project_id", projectIds),
|
||||
selectByIds(db, "document_versions", "document_id", documentIds),
|
||||
selectByIds(db, "document_edits", "document_id", documentIds),
|
||||
selectByIds(db, "tabular_cells", "review_id", reviewIds),
|
||||
]);
|
||||
|
||||
return {
|
||||
exported_at: new Date().toISOString(),
|
||||
user: { id: userId, email: userEmail ?? null },
|
||||
profile,
|
||||
api_keys: apiKeys,
|
||||
projects,
|
||||
project_subfolders: folders,
|
||||
documents,
|
||||
document_versions: versions,
|
||||
document_edits: edits,
|
||||
workflows,
|
||||
hidden_workflows: hiddenWorkflows,
|
||||
workflow_shares_by_user: workflowSharesByUser,
|
||||
workflow_shares_with_user: workflowSharesWithUser,
|
||||
chats: assistantChats,
|
||||
tabular_reviews: tabularReviews,
|
||||
tabular_cells: tabularCells,
|
||||
tabular_review_chats: tabularChats,
|
||||
shared_access: {
|
||||
projects: sharedProjects,
|
||||
tabular_reviews: sharedTabularReviews,
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue