1077 lines
45 KiB
JavaScript
1077 lines
45 KiB
JavaScript
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
export const IMPORT_FORMAT = "neta-supabase-export";
|
|
export const IMPORT_VERSION = 1;
|
|
|
|
const tableNames = [
|
|
"profiles",
|
|
"clients",
|
|
"client_activities",
|
|
"projects",
|
|
"project_planning_sections",
|
|
"project_revisions",
|
|
"tasks",
|
|
"calendar_events",
|
|
"finance_transactions",
|
|
"daily_logs",
|
|
"journals",
|
|
"chat_sessions",
|
|
"chat_messages",
|
|
"proposals",
|
|
"contracts",
|
|
"invoices",
|
|
"subscriptions",
|
|
"app_settings",
|
|
"document_embeddings",
|
|
];
|
|
|
|
const enumValues = {
|
|
clientStatus: ["active", "paused", "archived"],
|
|
pipelineStage: ["lead", "contacted", "proposal_sent", "won", "lost"],
|
|
projectType: ["client_project", "side_project"],
|
|
projectStatus: ["planning", "active", "paused", "completed", "cancelled"],
|
|
progressType: ["manual", "auto"],
|
|
taskStatus: ["todo", "in_progress", "done", "cancelled"],
|
|
priority: ["low", "medium", "high", "urgent"],
|
|
eventType: ["meeting", "focus", "deadline", "personal", "finance"],
|
|
financeType: ["income", "expense"],
|
|
paymentStatus: ["planned", "pending", "paid", "cancelled"],
|
|
activityType: ["note", "call", "meeting", "email"],
|
|
planningCategory: [
|
|
"overview",
|
|
"problem",
|
|
"goal",
|
|
"audience",
|
|
"scope",
|
|
"design_system",
|
|
"color_palette",
|
|
"typography",
|
|
"assets",
|
|
"notes",
|
|
],
|
|
revisionStatus: ["pending", "in_progress", "completed", "rejected"],
|
|
chatRole: ["system", "user", "assistant", "tool"],
|
|
proposalStatus: ["draft", "sent", "accepted", "rejected"],
|
|
contractStatus: ["draft", "active", "completed", "cancelled"],
|
|
invoiceStatus: ["draft", "sent", "paid", "overdue", "cancelled"],
|
|
billingCycle: ["weekly", "monthly", "yearly"],
|
|
subscriptionStatus: ["active", "cancelled"],
|
|
aiProvider: ["gemini", "openai", "groq", "ollama"],
|
|
};
|
|
|
|
export function loadImportBundle(inputPath) {
|
|
const absoluteInput = path.resolve(inputPath);
|
|
const bundlePath = fs.statSync(absoluteInput).isDirectory()
|
|
? path.join(absoluteInput, "export.json")
|
|
: absoluteInput;
|
|
const bundleDir = path.dirname(bundlePath);
|
|
const bundle = JSON.parse(fs.readFileSync(bundlePath, "utf8"));
|
|
|
|
if (bundle?.format !== IMPORT_FORMAT || bundle?.version !== IMPORT_VERSION) {
|
|
throw new Error(`Unsupported export format. Expected ${IMPORT_FORMAT} version ${IMPORT_VERSION}.`);
|
|
}
|
|
if (!bundle.source || typeof bundle.source.owner_user_id !== "string") {
|
|
throw new Error("Export source.owner_user_id is required.");
|
|
}
|
|
if (!bundle.tables || typeof bundle.tables !== "object" || Array.isArray(bundle.tables)) {
|
|
throw new Error("Export tables object is required.");
|
|
}
|
|
|
|
for (const table of tableNames) {
|
|
const rows = bundle.tables[table] ?? [];
|
|
if (!Array.isArray(rows)) throw new Error(`Export table ${table} must be an array.`);
|
|
bundle.tables[table] = rows;
|
|
}
|
|
const objects = bundle.storage?.objects ?? [];
|
|
if (!Array.isArray(objects)) throw new Error("Export storage.objects must be an array.");
|
|
bundle.storage = { ...(bundle.storage ?? {}), objects };
|
|
|
|
return { bundle, bundleDir, bundlePath };
|
|
}
|
|
|
|
export function prepareSupabaseImport({ bundle, bundleDir, targetOwnerUserId }) {
|
|
const sourceOwnerUserId = requiredText(bundle.source.owner_user_id, "source.owner_user_id");
|
|
const importedAt = isoTimestamp(bundle.exported_at ?? new Date().toISOString(), "exported_at");
|
|
const warnings = [];
|
|
|
|
for (const table of tableNames) {
|
|
for (const [index, row] of bundle.tables[table].entries()) {
|
|
if (!row || typeof row !== "object" || Array.isArray(row)) {
|
|
throw new Error(`${table}[${index}] must be an object.`);
|
|
}
|
|
if ("user_id" in row && row.user_id !== sourceOwnerUserId) {
|
|
throw new Error(`${table}[${index}].user_id is outside source owner scope.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const clients = bundle.tables.clients.map((row, index) => ({
|
|
id: requiredText(row.id, `clients[${index}].id`),
|
|
owner_user_id: targetOwnerUserId,
|
|
auth_user_id: null,
|
|
name: requiredText(row.name, `clients[${index}].name`),
|
|
company_name: optionalText(row.company_name),
|
|
email: optionalText(row.email),
|
|
phone: optionalText(row.phone),
|
|
website: optionalText(row.website),
|
|
status: enumValue(row.status ?? "active", enumValues.clientStatus, `clients[${index}].status`),
|
|
pipeline_stage: enumValue(
|
|
row.pipeline_stage ?? "lead",
|
|
enumValues.pipelineStage,
|
|
`clients[${index}].pipeline_stage`,
|
|
),
|
|
next_follow_up_date: dateValue(row.next_follow_up_date, `clients[${index}].next_follow_up_date`),
|
|
notes: optionalText(row.notes),
|
|
created_at: timestampMs(row.created_at ?? importedAt, `clients[${index}].created_at`),
|
|
updated_at: timestampMs(row.updated_at ?? row.created_at ?? importedAt, `clients[${index}].updated_at`),
|
|
}));
|
|
const clientIds = uniqueIds(clients, "clients");
|
|
|
|
const journalByDate = new Map();
|
|
const journalIdMap = new Map();
|
|
for (const [index, row] of bundle.tables.daily_logs.entries()) {
|
|
const entryDate = dateValue(row.log_date, `daily_logs[${index}].log_date`, true);
|
|
const entry = {
|
|
id: requiredText(row.id, `daily_logs[${index}].id`),
|
|
owner_user_id: targetOwnerUserId,
|
|
entry_date: entryDate,
|
|
mood_score: score(row.mood_score, `daily_logs[${index}].mood_score`, true),
|
|
energy_score: score(row.energy_score, `daily_logs[${index}].energy_score`, true),
|
|
work_satisfaction_score: score(
|
|
row.work_satisfaction_score,
|
|
`daily_logs[${index}].work_satisfaction_score`,
|
|
),
|
|
mood_label: null,
|
|
note: optionalText(row.note),
|
|
legacy_ai_metadata: null,
|
|
created_at: timestampMs(row.created_at ?? importedAt, `daily_logs[${index}].created_at`),
|
|
updated_at: timestampMs(row.updated_at ?? row.created_at ?? importedAt, `daily_logs[${index}].updated_at`),
|
|
};
|
|
if (journalByDate.has(entryDate)) throw new Error(`daily_logs contains duplicate date ${entryDate}.`);
|
|
journalByDate.set(entryDate, entry);
|
|
journalIdMap.set(entry.id, entry.id);
|
|
}
|
|
for (const [index, row] of bundle.tables.journals.entries()) {
|
|
const sourceId = requiredText(row.id, `journals[${index}].id`);
|
|
const entryDate = dateValue(row.date, `journals[${index}].date`, true);
|
|
const legacyMetadata = compactObject({
|
|
aiTags: arrayOfText(row.ai_tags, `journals[${index}].ai_tags`),
|
|
aiSentimentScore: row.ai_sentiment_score ?? null,
|
|
aiSummary: optionalText(row.ai_summary),
|
|
aiReflection: optionalText(row.ai_reflection),
|
|
analysisStatus: optionalText(row.analysis_status),
|
|
sourceJournalId: sourceId,
|
|
});
|
|
const existing = journalByDate.get(entryDate);
|
|
if (existing) {
|
|
existing.note = mergeJournalNotes(existing.note, optionalText(row.content));
|
|
existing.mood_label ||= optionalText(row.mood);
|
|
existing.legacy_ai_metadata = JSON.stringify({
|
|
...(existing.legacy_ai_metadata ? JSON.parse(existing.legacy_ai_metadata) : {}),
|
|
...legacyMetadata,
|
|
});
|
|
existing.updated_at = Math.max(
|
|
existing.updated_at,
|
|
timestampMs(row.updated_at ?? row.created_at ?? importedAt, `journals[${index}].updated_at`),
|
|
);
|
|
journalIdMap.set(sourceId, existing.id);
|
|
} else {
|
|
const entry = {
|
|
id: sourceId,
|
|
owner_user_id: targetOwnerUserId,
|
|
entry_date: entryDate,
|
|
mood_score: null,
|
|
energy_score: score(row.energy, `journals[${index}].energy`),
|
|
work_satisfaction_score: null,
|
|
mood_label: optionalText(row.mood),
|
|
note: optionalText(row.content),
|
|
legacy_ai_metadata: JSON.stringify(legacyMetadata),
|
|
created_at: timestampMs(row.created_at ?? importedAt, `journals[${index}].created_at`),
|
|
updated_at: timestampMs(row.updated_at ?? row.created_at ?? importedAt, `journals[${index}].updated_at`),
|
|
};
|
|
journalByDate.set(entryDate, entry);
|
|
journalIdMap.set(sourceId, sourceId);
|
|
}
|
|
}
|
|
const journalEntries = [...journalByDate.values()].sort((left, right) =>
|
|
left.entry_date.localeCompare(right.entry_date)
|
|
);
|
|
|
|
const projects = bundle.tables.projects.map((row, index) => {
|
|
const clientId = optionalId(row.client_id);
|
|
assertForeignKey(clientId, clientIds, `projects[${index}].client_id`);
|
|
return {
|
|
id: requiredText(row.id, `projects[${index}].id`),
|
|
owner_user_id: targetOwnerUserId,
|
|
client_id: clientId,
|
|
name: requiredText(row.name, `projects[${index}].name`),
|
|
type: enumValue(row.type ?? "client_project", enumValues.projectType, `projects[${index}].type`),
|
|
description: optionalText(row.description),
|
|
status: enumValue(row.status ?? "planning", enumValues.projectStatus, `projects[${index}].status`),
|
|
start_date: dateValue(row.start_date, `projects[${index}].start_date`),
|
|
due_date: dateValue(row.due_date, `projects[${index}].due_date`),
|
|
budget_amount_minor: moneyMinor(row.budget_amount, `projects[${index}].budget_amount`),
|
|
currency: currency(row.currency ?? "USD", `projects[${index}].currency`),
|
|
progress: boundedInteger(row.progress ?? 0, 0, 100, `projects[${index}].progress`),
|
|
progress_type: enumValue(
|
|
row.progress_type ?? "manual",
|
|
enumValues.progressType,
|
|
`projects[${index}].progress_type`,
|
|
),
|
|
revision_quota: boundedInteger(
|
|
row.revision_quota ?? 0,
|
|
0,
|
|
Number.MAX_SAFE_INTEGER,
|
|
`projects[${index}].revision_quota`,
|
|
),
|
|
legacy_cover_image_path: optionalText(row.cover_image_path),
|
|
cover_image_alt: optionalText(row.cover_image_alt),
|
|
created_at: timestampMs(row.created_at ?? importedAt, `projects[${index}].created_at`),
|
|
updated_at: timestampMs(row.updated_at ?? row.created_at ?? importedAt, `projects[${index}].updated_at`),
|
|
};
|
|
});
|
|
const projectIds = uniqueIds(projects, "projects");
|
|
|
|
const storage = prepareStorageObjects({
|
|
objects: bundle.storage.objects,
|
|
bundleDir,
|
|
targetOwnerUserId,
|
|
projectIds,
|
|
sourceOwnerUserId,
|
|
importedAt,
|
|
});
|
|
const assetReplacements = new Map();
|
|
for (const item of storage) {
|
|
for (const alias of item.aliases) assetReplacements.set(alias, `/api/files/${item.row.id}`);
|
|
}
|
|
for (const project of projects) {
|
|
if (project.legacy_cover_image_path && assetReplacements.has(project.legacy_cover_image_path)) {
|
|
project.legacy_cover_image_path = assetReplacements.get(project.legacy_cover_image_path);
|
|
}
|
|
}
|
|
|
|
const tasks = bundle.tables.tasks.map((row, index) => {
|
|
const status = row.status === "completed" ? "done" : row.status ?? "todo";
|
|
const clientId = optionalId(row.client_id);
|
|
const projectId = optionalId(row.project_id);
|
|
const sourceJournalId = optionalId(row.source_journal_id);
|
|
assertForeignKey(clientId, clientIds, `tasks[${index}].client_id`);
|
|
assertForeignKey(projectId, projectIds, `tasks[${index}].project_id`);
|
|
if (sourceJournalId && !journalIdMap.has(sourceJournalId)) {
|
|
throw new Error(`tasks[${index}].source_journal_id references missing journal ${sourceJournalId}.`);
|
|
}
|
|
return {
|
|
id: requiredText(row.id, `tasks[${index}].id`),
|
|
owner_user_id: targetOwnerUserId,
|
|
client_id: clientId,
|
|
project_id: projectId,
|
|
source_journal_entry_id: sourceJournalId ? journalIdMap.get(sourceJournalId) : null,
|
|
title: requiredText(row.title, `tasks[${index}].title`),
|
|
description: optionalText(row.description),
|
|
status: enumValue(status, enumValues.taskStatus, `tasks[${index}].status`),
|
|
priority: enumValue(row.priority ?? "medium", enumValues.priority, `tasks[${index}].priority`),
|
|
scheduled_date: dateValue(row.date, `tasks[${index}].date`),
|
|
due_at: timestampMs(row.due_at, `tasks[${index}].due_at`, false),
|
|
estimated_minutes: nonNegativeInteger(row.estimated_minutes, `tasks[${index}].estimated_minutes`),
|
|
actual_minutes: nonNegativeInteger(row.actual_minutes, `tasks[${index}].actual_minutes`),
|
|
ai_generated: booleanInteger(row.ai_generated),
|
|
is_public_to_client: booleanInteger(row.is_public_to_client),
|
|
created_at: timestampMs(row.created_at ?? importedAt, `tasks[${index}].created_at`),
|
|
updated_at: timestampMs(row.updated_at ?? row.created_at ?? importedAt, `tasks[${index}].updated_at`),
|
|
};
|
|
});
|
|
const taskIds = uniqueIds(tasks, "tasks");
|
|
|
|
const calendarEvents = bundle.tables.calendar_events.map((row, index) => {
|
|
const clientId = optionalId(row.client_id);
|
|
const projectId = optionalId(row.project_id);
|
|
const taskId = optionalId(row.task_id);
|
|
assertForeignKey(clientId, clientIds, `calendar_events[${index}].client_id`);
|
|
assertForeignKey(projectId, projectIds, `calendar_events[${index}].project_id`);
|
|
assertForeignKey(taskId, taskIds, `calendar_events[${index}].task_id`);
|
|
const startsAt = timestampMs(row.starts_at, `calendar_events[${index}].starts_at`);
|
|
const endsAt = timestampMs(row.ends_at, `calendar_events[${index}].ends_at`, false);
|
|
if (endsAt != null && endsAt < startsAt) {
|
|
throw new Error(`calendar_events[${index}].ends_at is before starts_at.`);
|
|
}
|
|
return {
|
|
id: requiredText(row.id, `calendar_events[${index}].id`),
|
|
owner_user_id: targetOwnerUserId,
|
|
client_id: clientId,
|
|
project_id: projectId,
|
|
task_id: taskId,
|
|
title: requiredText(row.title, `calendar_events[${index}].title`),
|
|
description: optionalText(row.description),
|
|
type: enumValue(row.type ?? "focus", enumValues.eventType, `calendar_events[${index}].type`),
|
|
starts_at: startsAt,
|
|
ends_at: endsAt,
|
|
created_at: timestampMs(row.created_at ?? importedAt, `calendar_events[${index}].created_at`),
|
|
updated_at: timestampMs(row.updated_at ?? row.created_at ?? importedAt, `calendar_events[${index}].updated_at`),
|
|
};
|
|
});
|
|
|
|
const financeTransactions = bundle.tables.finance_transactions.map((row, index) => {
|
|
const clientId = optionalId(row.client_id);
|
|
const projectId = optionalId(row.project_id);
|
|
assertForeignKey(clientId, clientIds, `finance_transactions[${index}].client_id`);
|
|
assertForeignKey(projectId, projectIds, `finance_transactions[${index}].project_id`);
|
|
return {
|
|
id: requiredText(row.id, `finance_transactions[${index}].id`),
|
|
owner_user_id: targetOwnerUserId,
|
|
client_id: clientId,
|
|
project_id: projectId,
|
|
type: enumValue(row.type, enumValues.financeType, `finance_transactions[${index}].type`),
|
|
amount_minor: moneyMinor(row.amount, `finance_transactions[${index}].amount`, false),
|
|
currency: currency(row.currency ?? "USD", `finance_transactions[${index}].currency`),
|
|
transaction_date: dateValue(
|
|
row.transaction_date,
|
|
`finance_transactions[${index}].transaction_date`,
|
|
true,
|
|
),
|
|
category: optionalText(row.category),
|
|
payment_status: enumValue(
|
|
row.payment_status ?? "planned",
|
|
enumValues.paymentStatus,
|
|
`finance_transactions[${index}].payment_status`,
|
|
),
|
|
description: optionalText(row.description),
|
|
created_at: timestampMs(row.created_at ?? importedAt, `finance_transactions[${index}].created_at`),
|
|
updated_at: timestampMs(row.updated_at ?? row.created_at ?? importedAt, `finance_transactions[${index}].updated_at`),
|
|
};
|
|
});
|
|
|
|
const clientActivities = bundle.tables.client_activities.map((row, index) => {
|
|
const clientId = requiredText(row.client_id, `client_activities[${index}].client_id`);
|
|
assertForeignKey(clientId, clientIds, `client_activities[${index}].client_id`);
|
|
return {
|
|
id: requiredText(row.id, `client_activities[${index}].id`),
|
|
owner_user_id: targetOwnerUserId,
|
|
client_id: clientId,
|
|
type: enumValue(row.type, enumValues.activityType, `client_activities[${index}].type`),
|
|
title: requiredText(row.title, `client_activities[${index}].title`),
|
|
content: optionalText(row.content),
|
|
activity_date: timestampMs(
|
|
row.activity_date ?? row.created_at ?? importedAt,
|
|
`client_activities[${index}].activity_date`,
|
|
),
|
|
created_at: timestampMs(row.created_at ?? importedAt, `client_activities[${index}].created_at`),
|
|
};
|
|
});
|
|
|
|
const planningSections = bundle.tables.project_planning_sections.map((row, index) => {
|
|
const projectId = requiredText(row.project_id, `project_planning_sections[${index}].project_id`);
|
|
assertForeignKey(projectId, projectIds, `project_planning_sections[${index}].project_id`);
|
|
return {
|
|
id: requiredText(row.id, `project_planning_sections[${index}].id`),
|
|
owner_user_id: targetOwnerUserId,
|
|
project_id: projectId,
|
|
category: enumValue(
|
|
row.category,
|
|
enumValues.planningCategory,
|
|
`project_planning_sections[${index}].category`,
|
|
),
|
|
title: requiredText(row.title, `project_planning_sections[${index}].title`),
|
|
content: replaceAssetReferences(optionalText(row.content), assetReplacements),
|
|
metadata: JSON.stringify(replaceAssetReferences(jsonObject(row.metadata), assetReplacements)),
|
|
sort_order: boundedInteger(
|
|
row.sort_order ?? 0,
|
|
0,
|
|
Number.MAX_SAFE_INTEGER,
|
|
`project_planning_sections[${index}].sort_order`,
|
|
),
|
|
created_at: timestampMs(row.created_at ?? importedAt, `project_planning_sections[${index}].created_at`),
|
|
updated_at: timestampMs(row.updated_at ?? row.created_at ?? importedAt, `project_planning_sections[${index}].updated_at`),
|
|
};
|
|
});
|
|
|
|
const projectRevisions = bundle.tables.project_revisions.map((row, index) => {
|
|
const projectId = requiredText(row.project_id, `project_revisions[${index}].project_id`);
|
|
const clientId = requiredText(row.client_id, `project_revisions[${index}].client_id`);
|
|
assertForeignKey(projectId, projectIds, `project_revisions[${index}].project_id`);
|
|
assertForeignKey(clientId, clientIds, `project_revisions[${index}].client_id`);
|
|
return {
|
|
id: requiredText(row.id, `project_revisions[${index}].id`),
|
|
owner_user_id: targetOwnerUserId,
|
|
project_id: projectId,
|
|
client_id: clientId,
|
|
requested_by_user_id: targetOwnerUserId,
|
|
description: requiredText(row.description, `project_revisions[${index}].description`),
|
|
status: enumValue(
|
|
row.status ?? "pending",
|
|
enumValues.revisionStatus,
|
|
`project_revisions[${index}].status`,
|
|
),
|
|
created_at: timestampMs(row.created_at ?? importedAt, `project_revisions[${index}].created_at`),
|
|
updated_at: timestampMs(row.updated_at ?? row.created_at ?? importedAt, `project_revisions[${index}].updated_at`),
|
|
};
|
|
});
|
|
if (projectRevisions.length > 0) {
|
|
warnings.push(
|
|
`${projectRevisions.length} historical revision requester was mapped to the target owner; client accounts must be re-invited.`,
|
|
);
|
|
}
|
|
|
|
const chatSessions = bundle.tables.chat_sessions.map((row, index) => ({
|
|
id: requiredText(row.id, `chat_sessions[${index}].id`),
|
|
owner_user_id: targetOwnerUserId,
|
|
title: requiredText(row.title, `chat_sessions[${index}].title`),
|
|
created_at: timestampMs(row.created_at ?? importedAt, `chat_sessions[${index}].created_at`),
|
|
updated_at: timestampMs(row.updated_at ?? row.created_at ?? importedAt, `chat_sessions[${index}].updated_at`),
|
|
}));
|
|
const chatSessionIds = uniqueIds(chatSessions, "chat_sessions");
|
|
|
|
const journalTargetIds = new Set(journalEntries.map((entry) => entry.id));
|
|
const chatMessages = bundle.tables.chat_messages.map((row, index) => {
|
|
const sessionId = requiredText(row.session_id, `chat_messages[${index}].session_id`);
|
|
assertForeignKey(sessionId, chatSessionIds, `chat_messages[${index}].session_id`);
|
|
const contextIds = arrayOfText(
|
|
row.context_journal_ids,
|
|
`chat_messages[${index}].context_journal_ids`,
|
|
).map((id) => journalIdMap.get(id) ?? id);
|
|
for (const id of contextIds) assertForeignKey(id, journalTargetIds, `chat_messages[${index}].context_journal_ids`);
|
|
return {
|
|
id: requiredText(row.id, `chat_messages[${index}].id`),
|
|
session_id: sessionId,
|
|
role: enumValue(row.role, enumValues.chatRole, `chat_messages[${index}].role`),
|
|
content: requiredText(row.content, `chat_messages[${index}].content`),
|
|
context_journal_entry_ids: JSON.stringify([...new Set(contextIds)]),
|
|
created_at: timestampMs(row.created_at ?? importedAt, `chat_messages[${index}].created_at`),
|
|
};
|
|
});
|
|
|
|
const proposals = bundle.tables.proposals.map((row, index) => {
|
|
const clientId = optionalId(row.client_id);
|
|
const projectId = optionalId(row.project_id);
|
|
assertForeignKey(clientId, clientIds, `proposals[${index}].client_id`);
|
|
assertForeignKey(projectId, projectIds, `proposals[${index}].project_id`);
|
|
return {
|
|
id: requiredText(row.id, `proposals[${index}].id`),
|
|
owner_user_id: targetOwnerUserId,
|
|
client_id: clientId,
|
|
project_id: projectId,
|
|
title: requiredText(row.title, `proposals[${index}].title`),
|
|
description: optionalText(row.description),
|
|
amount_minor: moneyMinor(row.amount ?? 0, `proposals[${index}].amount`, false),
|
|
currency: currency(row.currency ?? "TRY", `proposals[${index}].currency`),
|
|
status: enumValue(row.status ?? "draft", enumValues.proposalStatus, `proposals[${index}].status`),
|
|
valid_until: timestampMs(row.valid_until, `proposals[${index}].valid_until`, false),
|
|
created_at: timestampMs(row.created_at ?? importedAt, `proposals[${index}].created_at`),
|
|
updated_at: timestampMs(row.updated_at ?? row.created_at ?? importedAt, `proposals[${index}].updated_at`),
|
|
};
|
|
});
|
|
const proposalIds = uniqueIds(proposals, "proposals");
|
|
|
|
const contracts = bundle.tables.contracts.map((row, index) => {
|
|
const proposalId = optionalId(row.proposal_id);
|
|
const clientId = optionalId(row.client_id);
|
|
assertForeignKey(proposalId, proposalIds, `contracts[${index}].proposal_id`);
|
|
assertForeignKey(clientId, clientIds, `contracts[${index}].client_id`);
|
|
return {
|
|
id: requiredText(row.id, `contracts[${index}].id`),
|
|
owner_user_id: targetOwnerUserId,
|
|
proposal_id: proposalId,
|
|
client_id: clientId,
|
|
title: requiredText(row.title, `contracts[${index}].title`),
|
|
content: optionalText(row.content),
|
|
status: enumValue(row.status ?? "draft", enumValues.contractStatus, `contracts[${index}].status`),
|
|
signed_at: timestampMs(row.signed_at, `contracts[${index}].signed_at`, false),
|
|
created_at: timestampMs(row.created_at ?? importedAt, `contracts[${index}].created_at`),
|
|
updated_at: timestampMs(row.updated_at ?? row.created_at ?? importedAt, `contracts[${index}].updated_at`),
|
|
};
|
|
});
|
|
|
|
const invoices = bundle.tables.invoices.map((row, index) => {
|
|
const clientId = optionalId(row.client_id);
|
|
const projectId = optionalId(row.project_id);
|
|
assertForeignKey(clientId, clientIds, `invoices[${index}].client_id`);
|
|
assertForeignKey(projectId, projectIds, `invoices[${index}].project_id`);
|
|
return {
|
|
id: requiredText(row.id, `invoices[${index}].id`),
|
|
owner_user_id: targetOwnerUserId,
|
|
client_id: clientId,
|
|
project_id: projectId,
|
|
invoice_number: requiredText(row.invoice_number, `invoices[${index}].invoice_number`),
|
|
amount_minor: moneyMinor(row.amount ?? 0, `invoices[${index}].amount`, false),
|
|
tax_basis_points: percentageBasisPoints(row.tax_rate ?? 0, `invoices[${index}].tax_rate`),
|
|
currency: currency(row.currency ?? "TRY", `invoices[${index}].currency`),
|
|
status: enumValue(row.status ?? "draft", enumValues.invoiceStatus, `invoices[${index}].status`),
|
|
issue_date: dateValue(
|
|
row.issue_date ?? row.created_at ?? importedAt,
|
|
`invoices[${index}].issue_date`,
|
|
true,
|
|
),
|
|
due_date: dateValue(row.due_date, `invoices[${index}].due_date`),
|
|
paid_at: timestampMs(row.paid_at, `invoices[${index}].paid_at`, false),
|
|
created_at: timestampMs(row.created_at ?? importedAt, `invoices[${index}].created_at`),
|
|
updated_at: timestampMs(row.updated_at ?? row.created_at ?? importedAt, `invoices[${index}].updated_at`),
|
|
};
|
|
});
|
|
|
|
const subscriptions = bundle.tables.subscriptions.map((row, index) => ({
|
|
id: requiredText(row.id, `subscriptions[${index}].id`),
|
|
owner_user_id: targetOwnerUserId,
|
|
name: requiredText(row.name, `subscriptions[${index}].name`),
|
|
amount_minor: moneyMinor(row.amount ?? 0, `subscriptions[${index}].amount`, false),
|
|
currency: currency(row.currency ?? "TRY", `subscriptions[${index}].currency`),
|
|
billing_cycle: enumValue(
|
|
row.billing_cycle ?? "monthly",
|
|
enumValues.billingCycle,
|
|
`subscriptions[${index}].billing_cycle`,
|
|
),
|
|
next_billing_date: dateValue(row.next_billing_date, `subscriptions[${index}].next_billing_date`),
|
|
status: enumValue(
|
|
row.status ?? "active",
|
|
enumValues.subscriptionStatus,
|
|
`subscriptions[${index}].status`,
|
|
),
|
|
category: optionalText(row.category),
|
|
created_at: timestampMs(row.created_at ?? importedAt, `subscriptions[${index}].created_at`),
|
|
updated_at: timestampMs(row.updated_at ?? row.created_at ?? importedAt, `subscriptions[${index}].updated_at`),
|
|
}));
|
|
|
|
const settingsRow = bundle.tables.app_settings[0] ?? null;
|
|
if (bundle.tables.app_settings.length > 1) {
|
|
throw new Error("app_settings must contain at most one owner-scoped row.");
|
|
}
|
|
const preferences = settingsRow
|
|
? {
|
|
owner_user_id: targetOwnerUserId,
|
|
timezone: requiredText(settingsRow.timezone ?? "UTC", "app_settings[0].timezone"),
|
|
default_currency: currency(settingsRow.currency ?? "USD", "app_settings[0].currency"),
|
|
language: "tr",
|
|
date_format: "dd.MM.yyyy",
|
|
color_mode: "system",
|
|
sidebar_collapsed: 0,
|
|
created_at: isoTimestamp(settingsRow.created_at ?? importedAt, "app_settings[0].created_at"),
|
|
updated_at: isoTimestamp(settingsRow.updated_at ?? settingsRow.created_at ?? importedAt, "app_settings[0].updated_at"),
|
|
}
|
|
: null;
|
|
const aiSettings = settingsRow
|
|
? {
|
|
owner_user_id: targetOwnerUserId,
|
|
provider: normalizeAiProvider(settingsRow.ai_provider),
|
|
model: optionalText(settingsRow.ai_model),
|
|
created_at: isoTimestamp(settingsRow.created_at ?? importedAt, "app_settings[0].created_at"),
|
|
updated_at: isoTimestamp(settingsRow.updated_at ?? settingsRow.created_at ?? importedAt, "app_settings[0].updated_at"),
|
|
}
|
|
: null;
|
|
if (settingsRow?.api_key && String(settingsRow.api_key).trim()) {
|
|
warnings.push("Legacy app_settings.api_key was intentionally not imported.");
|
|
}
|
|
if (bundle.tables.document_embeddings.length > 0) {
|
|
warnings.push(
|
|
`${bundle.tables.document_embeddings.length} document_embeddings rows were intentionally archived outside runtime import.`,
|
|
);
|
|
}
|
|
if (clients.some((row) => row.auth_user_id == null) && bundle.tables.clients.some((row) => row.client_auth_id)) {
|
|
warnings.push("Legacy client auth links were cleared; client accounts must be re-invited.");
|
|
}
|
|
|
|
const ownerProfile = bundle.tables.profiles.find((row) => row.id === sourceOwnerUserId) ?? null;
|
|
const ownerDisplayName = ownerProfile
|
|
? [optionalText(ownerProfile.first_name), optionalText(ownerProfile.last_name)].filter(Boolean).join(" ")
|
|
: null;
|
|
|
|
const tables = {
|
|
clients,
|
|
journal_entries: journalEntries,
|
|
projects,
|
|
tasks,
|
|
calendar_events: calendarEvents,
|
|
finance_transactions: financeTransactions,
|
|
client_activities: clientActivities,
|
|
project_planning_sections: planningSections,
|
|
project_revisions: projectRevisions,
|
|
chat_sessions: chatSessions,
|
|
chat_messages: chatMessages,
|
|
proposals,
|
|
contracts,
|
|
invoices,
|
|
subscriptions,
|
|
};
|
|
for (const [name, rows] of Object.entries(tables)) uniqueIds(rows, name);
|
|
|
|
return {
|
|
sourceOwnerUserId,
|
|
targetOwnerUserId,
|
|
importedAt,
|
|
ownerDisplayName,
|
|
tables,
|
|
storage,
|
|
preferences,
|
|
aiSettings,
|
|
warnings,
|
|
sourceCounts: Object.fromEntries(tableNames.map((name) => [name, bundle.tables[name].length])),
|
|
targetCounts: {
|
|
...Object.fromEntries(Object.entries(tables).map(([name, rows]) => [name, rows.length])),
|
|
files: storage.length,
|
|
user_preferences: preferences ? 1 : 0,
|
|
user_ai_settings: aiSettings ? 1 : 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function validateTargetOwner(db, targetOwnerUserId) {
|
|
const owner = db.prepare(`
|
|
select u.id, u.email, p.role
|
|
from user u
|
|
inner join app_profiles p on p.auth_user_id = u.id
|
|
where u.id = ?
|
|
`).get(targetOwnerUserId);
|
|
if (!owner || owner.role !== "freelancer") {
|
|
throw new Error("Target owner must be an existing Better Auth freelancer profile.");
|
|
}
|
|
return owner;
|
|
}
|
|
|
|
export function assertTargetImportSafety(db, plan, allowExisting) {
|
|
const domainTables = Object.keys(plan.tables);
|
|
const existing = Object.fromEntries(
|
|
domainTables.map((table) => [
|
|
table,
|
|
db.prepare(`select count(*) as value from "${table}" where ${table === "chat_messages" ? "session_id in (select id from chat_sessions where owner_user_id = ?)" : table === "project_revisions" || table === "project_planning_sections" || table === "client_activities" ? "owner_user_id = ?" : table === "journal_entries" || table === "calendar_events" || table === "finance_transactions" || table === "chat_sessions" || table === "clients" || table === "projects" || table === "tasks" || table === "proposals" || table === "contracts" || table === "invoices" || table === "subscriptions" ? "owner_user_id = ?" : "1 = 0"}`)
|
|
.get(plan.targetOwnerUserId).value,
|
|
]),
|
|
);
|
|
const total = Object.values(existing).reduce((sum, value) => sum + Number(value), 0);
|
|
if (total > 0 && !allowExisting) {
|
|
throw new Error(
|
|
`Target already contains ${total} owner domain rows. Re-run with --allow-existing for idempotent upsert.`,
|
|
);
|
|
}
|
|
return existing;
|
|
}
|
|
|
|
export function stageImportFiles(plan, uploadsDir) {
|
|
const createdPaths = [];
|
|
for (const item of plan.storage) {
|
|
const targetPath = resolveWithin(uploadsDir, item.row.storage_path);
|
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
if (fs.existsSync(targetPath)) {
|
|
const actualHash = hashFile(targetPath);
|
|
if (actualHash !== item.row.sha256) {
|
|
throw new Error(`Existing target file checksum mismatch: ${item.row.storage_path}`);
|
|
}
|
|
continue;
|
|
}
|
|
fs.copyFileSync(item.sourcePath, targetPath, fs.constants.COPYFILE_EXCL);
|
|
fs.chmodSync(targetPath, 0o600);
|
|
createdPaths.push(targetPath);
|
|
}
|
|
return createdPaths;
|
|
}
|
|
|
|
export function rollbackStagedFiles(paths) {
|
|
for (const filePath of paths.reverse()) {
|
|
try {
|
|
fs.unlinkSync(filePath);
|
|
} catch (error) {
|
|
if (error.code !== "ENOENT") throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
export function applySupabaseImport(db, plan) {
|
|
const write = db.transaction(() => {
|
|
for (const [table, rows] of Object.entries(plan.tables)) {
|
|
for (const row of rows) upsertRow(db, table, row, ["id"]);
|
|
}
|
|
for (const item of plan.storage) upsertRow(db, "files", item.row, ["id"]);
|
|
|
|
if (plan.preferences) upsertRow(db, "user_preferences", plan.preferences, ["owner_user_id"]);
|
|
if (plan.aiSettings) {
|
|
db.prepare(`
|
|
insert into user_ai_settings (
|
|
owner_user_id, provider, model, encrypted_api_key, created_at, updated_at
|
|
) values (
|
|
@owner_user_id, @provider, @model, null, @created_at, @updated_at
|
|
)
|
|
on conflict(owner_user_id) do update set
|
|
provider = case when user_ai_settings.encrypted_api_key is null then excluded.provider else user_ai_settings.provider end,
|
|
model = case when user_ai_settings.encrypted_api_key is null then excluded.model else user_ai_settings.model end,
|
|
updated_at = excluded.updated_at
|
|
`).run(plan.aiSettings);
|
|
}
|
|
if (plan.ownerDisplayName) {
|
|
db.prepare("update user set name = ?, updated_at = ? where id = ?")
|
|
.run(plan.ownerDisplayName, Date.now(), plan.targetOwnerUserId);
|
|
db.prepare("update app_profiles set display_name = ?, updated_at = ? where auth_user_id = ?")
|
|
.run(plan.ownerDisplayName, Date.now(), plan.targetOwnerUserId);
|
|
}
|
|
|
|
const avatar = plan.storage.find((item) => item.row.kind === "avatar");
|
|
if (avatar) {
|
|
db.prepare("update user set image = ?, updated_at = ? where id = ?")
|
|
.run(`/api/files/${avatar.row.id}`, Date.now(), plan.targetOwnerUserId);
|
|
}
|
|
for (const item of plan.storage) {
|
|
for (const reference of item.references) {
|
|
if (reference.type === "project_cover") {
|
|
db.prepare(`
|
|
update projects
|
|
set legacy_cover_image_path = ?, updated_at = ?
|
|
where id = ? and owner_user_id = ?
|
|
`).run(
|
|
`/api/files/${item.row.id}`,
|
|
Date.now(),
|
|
reference.project_id,
|
|
plan.targetOwnerUserId,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
write.immediate();
|
|
|
|
const verification = {};
|
|
for (const [table, rows] of Object.entries(plan.tables)) {
|
|
verification[table] = verifyImportedIds(db, table, rows.map((row) => row.id));
|
|
}
|
|
verification.files = verifyImportedIds(db, "files", plan.storage.map((item) => item.row.id));
|
|
return verification;
|
|
}
|
|
|
|
function prepareStorageObjects({
|
|
objects,
|
|
bundleDir,
|
|
targetOwnerUserId,
|
|
projectIds,
|
|
sourceOwnerUserId,
|
|
importedAt,
|
|
}) {
|
|
let avatarCount = 0;
|
|
return objects.map((entry, index) => {
|
|
const label = `storage.objects[${index}]`;
|
|
const bucket = enumValue(entry.bucket, ["avatars", "project-assets"], `${label}.bucket`);
|
|
const objectPath = safeObjectPath(entry.object_path, `${label}.object_path`);
|
|
const localPath = safeRelativePath(entry.local_path, `${label}.local_path`);
|
|
const sourcePath = resolveWithin(bundleDir, localPath);
|
|
const stat = fs.lstatSync(sourcePath);
|
|
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`${label}.local_path is not a regular file.`);
|
|
if (stat.size === 0 || stat.size > 5 * 1024 * 1024) {
|
|
throw new Error(`${label} must be between 1 byte and 5 MiB.`);
|
|
}
|
|
const sha256 = requiredText(entry.sha256, `${label}.sha256`).toLowerCase();
|
|
if (!/^[0-9a-f]{64}$/.test(sha256) || hashFile(sourcePath) !== sha256) {
|
|
throw new Error(`${label} checksum mismatch.`);
|
|
}
|
|
if (entry.bytes !== stat.size) throw new Error(`${label}.bytes mismatch.`);
|
|
const projectId = bucket === "project-assets"
|
|
? requiredText(entry.project_id, `${label}.project_id`)
|
|
: null;
|
|
assertForeignKey(projectId, projectIds, `${label}.project_id`);
|
|
if (bucket === "avatars") {
|
|
avatarCount += 1;
|
|
if (avatarCount > 1) throw new Error("Only one owner avatar may be imported.");
|
|
}
|
|
const mimeType = validateImageFile(sourcePath, entry.mime_type, label);
|
|
const extension = extensionForMime(mimeType);
|
|
const id = `import-${crypto.createHash("sha256")
|
|
.update(`${bucket}:${objectPath}:${projectId ?? sourceOwnerUserId}`)
|
|
.digest("hex")
|
|
.slice(0, 24)}`;
|
|
const directory = bucket === "avatars" ? "avatars" : "project-assets";
|
|
const references = Array.isArray(entry.references) ? entry.references.map((reference, refIndex) => {
|
|
if (reference?.type !== "project_cover") {
|
|
throw new Error(`${label}.references[${refIndex}] has unsupported type.`);
|
|
}
|
|
const referenceProjectId = requiredText(
|
|
reference.project_id,
|
|
`${label}.references[${refIndex}].project_id`,
|
|
);
|
|
assertForeignKey(referenceProjectId, projectIds, `${label}.references[${refIndex}].project_id`);
|
|
if (referenceProjectId !== projectId) {
|
|
throw new Error(`${label}.references[${refIndex}] project does not match storage project_id.`);
|
|
}
|
|
return { type: "project_cover", project_id: referenceProjectId };
|
|
}) : [];
|
|
const aliases = [
|
|
objectPath,
|
|
optionalText(entry.source_url),
|
|
...(Array.isArray(entry.aliases) ? entry.aliases.map((alias) => requiredText(alias, `${label}.aliases`)) : []),
|
|
].filter(Boolean);
|
|
return {
|
|
sourcePath,
|
|
references,
|
|
aliases,
|
|
row: {
|
|
id,
|
|
owner_user_id: targetOwnerUserId,
|
|
uploaded_by_user_id: targetOwnerUserId,
|
|
auth_user_id: bucket === "avatars" ? targetOwnerUserId : null,
|
|
project_id: projectId,
|
|
kind: bucket === "avatars" ? "avatar" : "project_asset",
|
|
visibility: bucket === "avatars" ? "private" : booleanInteger(entry.portal_visible) ? "portal" : "private",
|
|
storage_path: `${directory}/${id}.${extension}`,
|
|
original_name: path.basename(entry.original_name ?? objectPath),
|
|
mime_type: mimeType,
|
|
byte_size: stat.size,
|
|
sha256,
|
|
created_at: timestampMs(entry.created_at ?? importedAt, `${label}.created_at`),
|
|
updated_at: timestampMs(entry.updated_at ?? entry.created_at ?? importedAt, `${label}.updated_at`),
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
function upsertRow(db, table, row, conflictColumns) {
|
|
const columns = Object.keys(row);
|
|
const updates = columns.filter((column) => !conflictColumns.includes(column));
|
|
const quotedColumns = columns.map(quoteIdentifier).join(", ");
|
|
const placeholders = columns.map((column) => `@${column}`).join(", ");
|
|
const conflict = conflictColumns.map(quoteIdentifier).join(", ");
|
|
const updateSql = updates.map((column) =>
|
|
`${quoteIdentifier(column)} = excluded.${quoteIdentifier(column)}`
|
|
).join(", ");
|
|
db.prepare(`
|
|
insert into ${quoteIdentifier(table)} (${quotedColumns})
|
|
values (${placeholders})
|
|
on conflict(${conflict}) do update set ${updateSql}
|
|
`).run(row);
|
|
}
|
|
|
|
function verifyImportedIds(db, table, ids) {
|
|
if (ids.length === 0) return 0;
|
|
const statement = db.prepare(`select 1 as value from ${quoteIdentifier(table)} where id = ?`);
|
|
for (const id of ids) {
|
|
if (!statement.get(id)) throw new Error(`Post-import verification failed for ${table}:${id}.`);
|
|
}
|
|
return ids.length;
|
|
}
|
|
|
|
function uniqueIds(rows, label) {
|
|
const values = new Set();
|
|
for (const row of rows) {
|
|
if (values.has(row.id)) throw new Error(`${label} contains duplicate id ${row.id}.`);
|
|
values.add(row.id);
|
|
}
|
|
return values;
|
|
}
|
|
|
|
function assertForeignKey(value, ids, label) {
|
|
if (value != null && !ids.has(value)) throw new Error(`${label} references missing id ${value}.`);
|
|
}
|
|
|
|
function enumValue(value, allowed, label) {
|
|
if (typeof value !== "string" || !allowed.includes(value)) {
|
|
throw new Error(`${label} has unknown value ${JSON.stringify(value)}.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function normalizeAiProvider(value) {
|
|
const normalized = value === "google" ? "gemini" : value ?? "ollama";
|
|
return enumValue(normalized, enumValues.aiProvider, "app_settings[0].ai_provider");
|
|
}
|
|
|
|
function requiredText(value, label) {
|
|
if (typeof value !== "string" || !value.trim()) throw new Error(`${label} is required.`);
|
|
return value.trim();
|
|
}
|
|
|
|
function optionalText(value) {
|
|
if (value == null) return null;
|
|
const text = String(value).trim();
|
|
return text || null;
|
|
}
|
|
|
|
function optionalId(value) {
|
|
return optionalText(value);
|
|
}
|
|
|
|
function currency(value, label) {
|
|
const normalized = requiredText(String(value), label).toUpperCase();
|
|
if (!/^[A-Z]{3}$/.test(normalized)) throw new Error(`${label} must be a 3-letter currency.`);
|
|
return normalized;
|
|
}
|
|
|
|
function moneyMinor(value, label, nullable = true) {
|
|
if (value == null || value === "") {
|
|
if (nullable) return null;
|
|
throw new Error(`${label} is required.`);
|
|
}
|
|
const minor = decimalScale(value, 2, label);
|
|
if (minor < 0) throw new Error(`${label} cannot be negative.`);
|
|
return minor;
|
|
}
|
|
|
|
function percentageBasisPoints(value, label) {
|
|
const points = decimalScale(value, 2, label);
|
|
if (points < 0 || points > 10_000) throw new Error(`${label} must be between 0 and 100.`);
|
|
return points;
|
|
}
|
|
|
|
function decimalScale(value, scale, label) {
|
|
const text = String(value).trim();
|
|
const match = /^([+-]?)(\d+)(?:\.(\d+))?$/.exec(text);
|
|
if (!match || (match[3]?.length ?? 0) > scale) {
|
|
throw new Error(`${label} must have at most ${scale} decimal places.`);
|
|
}
|
|
const sign = match[1] === "-" ? -1 : 1;
|
|
const fraction = (match[3] ?? "").padEnd(scale, "0");
|
|
const result = sign * (Number(match[2]) * 10 ** scale + Number(fraction || 0));
|
|
if (!Number.isSafeInteger(result)) throw new Error(`${label} is outside safe integer range.`);
|
|
return result;
|
|
}
|
|
|
|
function boundedInteger(value, min, max, label) {
|
|
const number = Number(value);
|
|
if (!Number.isInteger(number) || number < min || number > max) {
|
|
throw new Error(`${label} must be an integer between ${min} and ${max}.`);
|
|
}
|
|
return number;
|
|
}
|
|
|
|
function nonNegativeInteger(value, label) {
|
|
if (value == null || value === "") return null;
|
|
return boundedInteger(value, 0, Number.MAX_SAFE_INTEGER, label);
|
|
}
|
|
|
|
function score(value, label, required = false) {
|
|
if (value == null || value === "") {
|
|
if (required) throw new Error(`${label} is required.`);
|
|
return null;
|
|
}
|
|
return boundedInteger(value, 1, 5, label);
|
|
}
|
|
|
|
function booleanInteger(value) {
|
|
return value === true || value === 1 || value === "true" ? 1 : 0;
|
|
}
|
|
|
|
function dateValue(value, label, required = false) {
|
|
if (value == null || value === "") {
|
|
if (required) throw new Error(`${label} is required.`);
|
|
return null;
|
|
}
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) throw new Error(`${label} is not a valid date.`);
|
|
return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value)
|
|
? value
|
|
: date.toISOString().slice(0, 10);
|
|
}
|
|
|
|
function timestampMs(value, label, required = true) {
|
|
if (value == null || value === "") {
|
|
if (required) throw new Error(`${label} is required.`);
|
|
return null;
|
|
}
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) throw new Error(`${label} is not a valid timestamp.`);
|
|
return date.getTime();
|
|
}
|
|
|
|
function isoTimestamp(value, label) {
|
|
const milliseconds = timestampMs(value, label);
|
|
return new Date(milliseconds).toISOString();
|
|
}
|
|
|
|
function arrayOfText(value, label) {
|
|
if (value == null) return [];
|
|
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
|
|
throw new Error(`${label} must be an array of strings.`);
|
|
}
|
|
return value.map((item) => item.trim()).filter(Boolean);
|
|
}
|
|
|
|
function jsonObject(value) {
|
|
if (value == null) return {};
|
|
if (typeof value === "string") {
|
|
const parsed = JSON.parse(value);
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
throw new Error("Planning metadata must be a JSON object.");
|
|
}
|
|
return parsed;
|
|
}
|
|
if (typeof value !== "object" || Array.isArray(value)) {
|
|
throw new Error("Planning metadata must be a JSON object.");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function compactObject(value) {
|
|
return Object.fromEntries(Object.entries(value).filter(([, item]) =>
|
|
item != null && (!Array.isArray(item) || item.length > 0)
|
|
));
|
|
}
|
|
|
|
function mergeJournalNotes(dailyNote, journalContent) {
|
|
if (!journalContent) return dailyNote;
|
|
if (!dailyNote) return journalContent;
|
|
if (dailyNote.includes(journalContent)) return dailyNote;
|
|
return `${dailyNote}\n\n[Legacy journal]\n${journalContent}`;
|
|
}
|
|
|
|
function replaceAssetReferences(value, replacements) {
|
|
if (typeof value === "string") {
|
|
if (replacements.has(value)) return replacements.get(value);
|
|
let result = value;
|
|
for (const [legacy, replacement] of replacements) {
|
|
if (legacy && result.includes(legacy)) result = result.split(legacy).join(replacement);
|
|
}
|
|
return result;
|
|
}
|
|
if (Array.isArray(value)) return value.map((item) => replaceAssetReferences(item, replacements));
|
|
if (value && typeof value === "object") {
|
|
return Object.fromEntries(
|
|
Object.entries(value).map(([key, item]) => [key, replaceAssetReferences(item, replacements)]),
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function safeObjectPath(value, label) {
|
|
const text = requiredText(value, label).replace(/\\/g, "/");
|
|
if (text.startsWith("/") || text.split("/").includes("..")) throw new Error(`${label} is unsafe.`);
|
|
return text;
|
|
}
|
|
|
|
function safeRelativePath(value, label) {
|
|
return safeObjectPath(value, label);
|
|
}
|
|
|
|
function validateImageFile(filePath, rawMimeType, label) {
|
|
const mimeType = requiredText(rawMimeType, `${label}.mime_type`).toLowerCase();
|
|
const bytes = fs.readFileSync(filePath);
|
|
const ascii = (start, end) => bytes.subarray(start, end).toString("ascii");
|
|
const valid = (
|
|
(mimeType === "image/png"
|
|
&& bytes.length >= 8
|
|
&& bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
|
|
|| (mimeType === "image/jpeg" && bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)
|
|
|| (mimeType === "image/webp" && bytes.length >= 12 && ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP")
|
|
|| (mimeType === "image/gif" && ["GIF87a", "GIF89a"].includes(ascii(0, 6)))
|
|
);
|
|
if (!valid) throw new Error(`${label} content does not match its supported image MIME type.`);
|
|
return mimeType;
|
|
}
|
|
|
|
function extensionForMime(mimeType) {
|
|
return {
|
|
"image/png": "png",
|
|
"image/jpeg": "jpg",
|
|
"image/webp": "webp",
|
|
"image/gif": "gif",
|
|
}[mimeType];
|
|
}
|
|
|
|
function resolveWithin(root, relativePath) {
|
|
const normalizedRoot = path.resolve(root);
|
|
const resolved = path.resolve(normalizedRoot, relativePath);
|
|
if (!resolved.startsWith(`${normalizedRoot}${path.sep}`)) {
|
|
throw new Error(`Path escapes root: ${relativePath}`);
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
function hashFile(filePath) {
|
|
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
|
}
|
|
|
|
function quoteIdentifier(value) {
|
|
if (!/^[a-z_][a-z0-9_]*$/.test(value)) throw new Error(`Unsafe SQL identifier ${value}.`);
|
|
return `"${value}"`;
|
|
}
|