diff --git a/scripts/backup.mjs b/scripts/backup.mjs index 7a0199b..a0fb147 100644 --- a/scripts/backup.mjs +++ b/scripts/backup.mjs @@ -4,6 +4,7 @@ import path from "node:path"; import Database from "better-sqlite3"; import { applySqlitePragmas, ensureDataLayout } from "./lib/data-dir.mjs"; +const args = parseArgs(process.argv.slice(2)); const config = ensureDataLayout(); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const backupDir = path.join(config.backupsDir, `neta-${timestamp}`); @@ -24,6 +25,8 @@ try { copyDirectoryIfExists(config.uploadsDir, uploadsBackupDir); const manifest = { + format: "neta-backup", + version: 1, createdAt: new Date().toISOString(), source: { dataDir: config.dataDir, @@ -39,7 +42,45 @@ const manifest = { fs.writeFileSync(path.join(backupDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); +const removedBackups = applyRetention(config.backupsDir, backupDir, args.retentionCount); console.log(`Backup created at ${backupDir}`); +if (args.retentionCount !== null) { + console.log( + `Retention kept the newest ${args.retentionCount} backup(s); removed ${removedBackups.length}.`, + ); +} + +function parseArgs(values) { + let retentionCount = parseOptionalPositiveInteger( + process.env.BACKUP_RETENTION_COUNT, + "BACKUP_RETENTION_COUNT", + ); + + for (let index = 0; index < values.length; index += 1) { + const value = values[index]; + if (value === "--retention-count") { + retentionCount = parseOptionalPositiveInteger(values[index + 1], "--retention-count"); + if (retentionCount === null) { + throw new Error("--retention-count requires a positive integer."); + } + index += 1; + } else { + throw new Error( + "Usage: node scripts/backup.mjs [--retention-count ]", + ); + } + } + + return { retentionCount }; +} + +function parseOptionalPositiveInteger(value, name) { + if (value === undefined || value === null || value === "") return null; + if (!/^[1-9]\d*$/.test(value)) { + throw new Error(`${name} must be a positive integer.`); + } + return Number.parseInt(value, 10); +} function copyDirectoryIfExists(sourceDir, targetDir) { if (!fs.existsSync(sourceDir)) { @@ -52,10 +93,14 @@ function copyDirectoryIfExists(sourceDir, targetDir) { const sourcePath = path.join(sourceDir, entry.name); const targetPath = path.join(targetDir, entry.name); - if (entry.isDirectory()) { + if (entry.isSymbolicLink()) { + throw new Error(`Upload tree contains a symbolic link: ${sourcePath}`); + } else if (entry.isDirectory()) { copyDirectoryIfExists(sourcePath, targetPath); } else if (entry.isFile()) { fs.copyFileSync(sourcePath, targetPath); + } else { + throw new Error(`Upload tree contains an unsupported filesystem entry: ${sourcePath}`); } } } @@ -66,7 +111,9 @@ function collectFiles(rootDir) { for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) { const entryPath = path.join(rootDir, entry.name); - if (entry.isDirectory()) { + if (entry.isSymbolicLink()) { + throw new Error(`Backup contains a symbolic link: ${entryPath}`); + } else if (entry.isDirectory()) { files.push(...collectFiles(entryPath)); } else if (entry.isFile()) { files.push(entryPath); @@ -81,3 +128,31 @@ function hashFile(filePath) { hash.update(fs.readFileSync(filePath)); return hash.digest("hex"); } + +function applyRetention(backupsDir, currentBackupDir, retentionCount) { + if (retentionCount === null) return []; + + const candidates = fs + .readdirSync(backupsDir, { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + !entry.isSymbolicLink() && + /^neta-\d{4}-\d{2}-\d{2}T/.test(entry.name) && + fs.existsSync(path.join(backupsDir, entry.name, "manifest.json")), + ) + .map((entry) => path.join(backupsDir, entry.name)) + .sort((left, right) => path.basename(right).localeCompare(path.basename(left))); + + const currentIndex = candidates.indexOf(currentBackupDir); + if (currentIndex > 0) { + candidates.splice(currentIndex, 1); + candidates.unshift(currentBackupDir); + } + + const removed = candidates.slice(retentionCount); + for (const candidate of removed) { + fs.rmSync(candidate, { recursive: true, force: false }); + } + return removed; +} diff --git a/scripts/import-supabase.mjs b/scripts/import-supabase.mjs new file mode 100644 index 0000000..529650b --- /dev/null +++ b/scripts/import-supabase.mjs @@ -0,0 +1,86 @@ +import fs from "node:fs"; +import path from "node:path"; +import Database from "better-sqlite3"; +import { applySqlitePragmas, ensureDataLayout, getDataConfig } from "./lib/data-dir.mjs"; +import { + applySupabaseImport, + assertTargetImportSafety, + loadImportBundle, + prepareSupabaseImport, + rollbackStagedFiles, + stageImportFiles, + validateTargetOwner, +} from "./lib/supabase-import.mjs"; + +const args = parseArgs(process.argv.slice(2)); +if (!args.from || !args.ownerUserId) { + throw new Error( + "Usage: node scripts/import-supabase.mjs --from --owner-user-id [--dry-run] [--allow-existing] [--report ]", + ); +} + +const config = ensureDataLayout(getDataConfig(process.env)); +const { bundle, bundleDir, bundlePath } = loadImportBundle(args.from); +const plan = prepareSupabaseImport({ + bundle, + bundleDir, + targetOwnerUserId: args.ownerUserId, +}); +const db = new Database(config.databasePath); +let stagedFiles = []; + +try { + applySqlitePragmas(db); + const owner = validateTargetOwner(db, args.ownerUserId); + const existingCounts = assertTargetImportSafety(db, plan, args.allowExisting || args.dryRun); + const report = { + format: "neta-supabase-import-report", + version: 1, + mode: args.dryRun ? "dry-run" : "apply", + exportPath: bundlePath, + sourceOwnerUserId: plan.sourceOwnerUserId, + targetOwnerUserId: plan.targetOwnerUserId, + targetOwnerEmail: owner.email, + sourceCounts: plan.sourceCounts, + targetCounts: plan.targetCounts, + existingCounts, + warnings: plan.warnings, + verification: null, + completedAt: null, + }; + + if (!args.dryRun) { + stagedFiles = stageImportFiles(plan, config.uploadsDir); + try { + report.verification = applySupabaseImport(db, plan); + report.completedAt = new Date().toISOString(); + } catch (error) { + rollbackStagedFiles(stagedFiles); + throw error; + } + } + + const reportPath = path.resolve( + args.report ?? path.join(config.dataDir, args.dryRun ? "import-dry-run-report.json" : "import-report.json"), + ); + fs.mkdirSync(path.dirname(reportPath), { recursive: true }); + fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 }); + console.log(`${args.dryRun ? "Import dry-run" : "Import"} completed. Report: ${reportPath}`); + for (const warning of report.warnings) console.warn(`Warning: ${warning}`); +} finally { + db.close(); +} + +function parseArgs(values) { + const parsed = { dryRun: false, allowExisting: false }; + for (let index = 0; index < values.length; index += 1) { + const value = values[index]; + if (value === "--dry-run") parsed.dryRun = true; + else if (value === "--allow-existing") parsed.allowExisting = true; + else if (value === "--from") parsed.from = values[++index]; + else if (value === "--owner-user-id") parsed.ownerUserId = values[++index]; + else if (value === "--report") parsed.report = values[++index]; + else throw new Error(`Unknown argument: ${value}`); + } + return parsed; +} diff --git a/scripts/lib/supabase-import.mjs b/scripts/lib/supabase-import.mjs new file mode 100644 index 0000000..24a9c28 --- /dev/null +++ b/scripts/lib/supabase-import.mjs @@ -0,0 +1,1076 @@ +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}"`; +} diff --git a/scripts/phase1-smoke.mjs b/scripts/phase1-smoke.mjs index cc379ab..1e46a08 100644 --- a/scripts/phase1-smoke.mjs +++ b/scripts/phase1-smoke.mjs @@ -58,6 +58,20 @@ execFileSync(process.execPath, ["scripts/backup.mjs"], { env, stdio: "inherit", }); +for (let index = 0; index < 2; index += 1) { + execFileSync( + process.execPath, + ["scripts/backup.mjs", "--retention-count", "2"], + { cwd: process.cwd(), env, stdio: "inherit" }, + ); +} + +const retainedBackups = fs + .readdirSync(path.join(smokeRoot, "backups")) + .filter((name) => name.startsWith("neta-")); +if (retainedBackups.length !== 2) { + throw new Error(`Backup retention smoke check failed: expected 2, received ${retainedBackups.length}.`); +} const backupDir = fs .readdirSync(path.join(smokeRoot, "backups")) @@ -69,6 +83,14 @@ if (!backupDir) { throw new Error("Backup smoke check failed: no backup directory produced."); } +execFileSync(process.execPath, ["scripts/migrate.mjs"], { + cwd: process.cwd(), + env: { ...process.env, DATA_DIR: restoreRoot, DATABASE_PATH: "" }, + stdio: "inherit", +}); +const staleUploadPath = path.join(restoreRoot, "uploads", "stale.txt"); +fs.writeFileSync(staleUploadPath, "must-be-replaced"); + execFileSync(process.execPath, ["scripts/restore.mjs", "--from", backupDir, "--target", restoreRoot, "--force"], { cwd: process.cwd(), env: process.env, @@ -96,6 +118,9 @@ const restoredUploadFixture = path.join( if (fs.readFileSync(restoredUploadFixture, "utf8") !== "neta-upload-backup-fixture") { throw new Error("Restore smoke check failed: upload fixture missing or corrupted."); } +if (fs.existsSync(staleUploadPath)) { + throw new Error("Atomic restore smoke check failed: stale upload tree was not replaced."); +} fs.appendFileSync(path.join(backupDir, "uploads", "project-assets", "backup-fixture.txt"), "-tampered"); let corruptedBackupRejected = false; diff --git a/scripts/phase8-import-smoke.mjs b/scripts/phase8-import-smoke.mjs new file mode 100644 index 0000000..ccb1fbb --- /dev/null +++ b/scripts/phase8-import-smoke.mjs @@ -0,0 +1,368 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import Database from "better-sqlite3"; + +const root = path.join(process.cwd(), ".data", `phase8-import-smoke-${Date.now()}`); +const dataDir = path.join(root, "data"); +const exportDir = path.join(root, "export"); +const databasePath = path.join(dataDir, "neta.db"); +const ownerUserId = "phase8-target-owner"; +const sourceOwnerUserId = "00000000-0000-4000-8000-000000000008"; +const env = { ...process.env, DATA_DIR: dataDir, DATABASE_PATH: databasePath }; +const rollbackDir = path.join(root, "rollback"); + +fs.mkdirSync(path.join(exportDir, "storage", "avatars"), { recursive: true }); +fs.mkdirSync(path.join(exportDir, "storage", "project-assets"), { recursive: true }); +execFileSync(process.execPath, ["scripts/migrate.mjs"], { + cwd: process.cwd(), + env, + stdio: "inherit", +}); + +const db = new Database(databasePath); +db.pragma("foreign_keys = ON"); +db.prepare(` + insert into user (id, name, email, email_verified, created_at, updated_at) + values (?, ?, ?, 1, ?, ?) +`).run(ownerUserId, "Target Owner", "phase8-owner@example.com", Date.now(), Date.now()); +db.prepare(` + insert into app_profiles (auth_user_id, email, display_name, role, disabled, created_at, updated_at) + values (?, ?, ?, 'freelancer', 0, ?, ?) +`).run(ownerUserId, "phase8-owner@example.com", "Target Owner", Date.now(), Date.now()); +db.close(); + +execFileSync(process.execPath, ["scripts/backup.mjs"], { + cwd: process.cwd(), + env, + stdio: "inherit", +}); +const preCutoverBackupDir = fs + .readdirSync(path.join(dataDir, "backups")) + .map((name) => path.join(dataDir, "backups", name)) + .sort() + .at(-1); +assert.ok(preCutoverBackupDir, "Pre-cutover backup must exist"); + +const avatarBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]); +const coverBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 5, 6, 7, 8]); +const avatarLocalPath = "storage/avatars/avatar.png"; +const coverLocalPath = "storage/project-assets/cover.png"; +fs.writeFileSync(path.join(exportDir, avatarLocalPath), avatarBytes); +fs.writeFileSync(path.join(exportDir, coverLocalPath), coverBytes); + +const baseBundle = createBundle(); +writeBundle(exportDir, baseBundle); + +runImport(["--dry-run"]); +let check = new Database(databasePath); +assert.equal(check.prepare("select count(*) as value from clients").get().value, 0, "Dry-run must not mutate"); +check.close(); + +runImport([]); +runImport(["--allow-existing"]); + +check = new Database(databasePath); +check.pragma("foreign_keys = ON"); +try { + assert.equal(check.prepare("select count(*) as value from clients").get().value, 1); + assert.equal(check.prepare("select count(*) as value from projects").get().value, 1); + assert.equal(check.prepare("select count(*) as value from files").get().value, 2); + assert.equal(check.prepare("select count(*) as value from journal_entries").get().value, 1); + assert.equal( + check.prepare("select status from tasks where id = 'source-task'").get().status, + "done", + "Legacy completed task status must normalize to done", + ); + assert.equal( + check.prepare("select source_journal_entry_id as value from tasks where id = 'source-task'").get().value, + "source-daily", + "Merged journal references must target the canonical daily log", + ); + assert.equal( + check.prepare("select amount_minor as value from finance_transactions where id = 'source-finance'").get().value, + 1234, + "Money must convert to integer minor units exactly", + ); + assert.equal( + check.prepare("select tax_basis_points as value from invoices where id = 'source-invoice'").get().value, + 1850, + "Tax percentage must convert to basis points exactly", + ); + const journal = check.prepare(` + select note, legacy_ai_metadata as legacyAiMetadata + from journal_entries where id = 'source-daily' + `).get(); + assert.match(journal.note, /Daily note/); + assert.match(journal.note, /Legacy journal content/); + assert.match(journal.legacyAiMetadata, /source-journal/); + const settings = check.prepare(` + select p.timezone, p.default_currency as defaultCurrency, + a.provider, a.model, a.encrypted_api_key as encryptedApiKey + from user_preferences p + inner join user_ai_settings a on a.owner_user_id = p.owner_user_id + where p.owner_user_id = ? + `).get(ownerUserId); + assert.deepEqual(settings, { + timezone: "Europe/Istanbul", + defaultCurrency: "TRY", + provider: "gemini", + model: "gemini-test-model", + encryptedApiKey: null, + }); + assert.equal( + check.prepare("select auth_user_id as value from clients where id = 'source-client'").get().value, + null, + "Legacy client auth links must not be imported", + ); + assert.equal( + check.prepare("select requested_by_user_id as value from project_revisions where id = 'source-revision'").get().value, + ownerUserId, + "Historical revision requester must use a valid target principal", + ); + const project = check.prepare(` + select legacy_cover_image_path as cover from projects where id = 'source-project' + `).get(); + assert.match(project.cover, /^\/api\/files\/import-/); + const section = check.prepare(` + select metadata from project_planning_sections where id = 'source-section' + `).get(); + assert.match(section.metadata, /\/api\/files\/import-/); + assert.equal(check.prepare("select name from user where id = ?").get(ownerUserId).name, "Source Owner"); + assert.deepEqual(check.pragma("foreign_key_check"), [], "Imported database must satisfy all foreign keys"); +} finally { + check.close(); +} + +const reportText = fs.readFileSync(path.join(dataDir, "import-report.json"), "utf8"); +assert.doesNotMatch(reportText, /legacy-plain-text-secret|source-password-secret/); +assert.match(reportText, /intentionally not imported/); + +const invalidEnumDir = cloneExport("invalid-enum"); +const invalidEnum = structuredClone(baseBundle); +invalidEnum.tables.tasks[0].status = "mystery"; +writeBundle(invalidEnumDir, invalidEnum); +assertImportFails(invalidEnumDir, /unknown value/); + +const invalidForeignKeyDir = cloneExport("invalid-foreign-key"); +const invalidForeignKey = structuredClone(baseBundle); +invalidForeignKey.tables.projects[0].client_id = "missing-client"; +writeBundle(invalidForeignKeyDir, invalidForeignKey); +assertImportFails(invalidForeignKeyDir, /references missing id/); + +const unsafeStorageDir = cloneExport("unsafe-storage"); +const unsafeStorage = structuredClone(baseBundle); +unsafeStorage.storage.objects[0].local_path = "../escape.png"; +writeBundle(unsafeStorageDir, unsafeStorage); +assertImportFails(unsafeStorageDir, /unsafe|escapes root/); + +execFileSync( + process.execPath, + [ + "scripts/restore.mjs", + "--from", + preCutoverBackupDir, + "--target", + rollbackDir, + "--force", + ], + { cwd: process.cwd(), env: process.env, stdio: "inherit" }, +); +const rollbackDb = new Database(path.join(rollbackDir, "neta.db"), { readonly: true }); +try { + assert.equal( + rollbackDb.prepare("select count(*) as value from clients").get().value, + 0, + "Rollback backup must restore the pre-import client count", + ); + assert.equal( + rollbackDb.prepare("select count(*) as value from user where id = ?").get(ownerUserId).value, + 1, + "Rollback backup must preserve the Better Auth owner", + ); +} finally { + rollbackDb.close(); +} + +console.log("Phase 8 import smoke passed: dry-run, normalization, files, idempotency, negative validation and rollback rehearsal verified."); + +function runImport(extraArgs, from = exportDir) { + execFileSync( + process.execPath, + [ + "scripts/import-supabase.mjs", + "--from", + from, + "--owner-user-id", + ownerUserId, + ...extraArgs, + ], + { cwd: process.cwd(), env, stdio: "inherit" }, + ); +} + +function assertImportFails(from, pattern) { + assert.throws( + () => execFileSync( + process.execPath, + [ + "scripts/import-supabase.mjs", + "--from", + from, + "--owner-user-id", + ownerUserId, + "--dry-run", + ], + { cwd: process.cwd(), env, stdio: "pipe" }, + ), + (error) => pattern.test(`${error.stdout ?? ""}\n${error.stderr ?? ""}`), + ); +} + +function cloneExport(name) { + const destination = path.join(root, name); + fs.cpSync(exportDir, destination, { recursive: true }); + return destination; +} + +function writeBundle(directory, bundle) { + fs.writeFileSync(path.join(directory, "export.json"), `${JSON.stringify(bundle, null, 2)}\n`); +} + +function hash(bytes) { + return crypto.createHash("sha256").update(bytes).digest("hex"); +} + +function createBundle() { + const createdAt = "2026-07-17T10:00:00.000Z"; + 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 tables = Object.fromEntries(tableNames.map((name) => [name, []])); + + tables.profiles.push({ + id: sourceOwnerUserId, + first_name: "Source", + last_name: "Owner", + avatar_url: "https://legacy.example/storage/v1/object/public/avatars/source/avatar.png", + }); + tables.clients.push({ + id: "source-client", user_id: sourceOwnerUserId, client_auth_id: "legacy-client-auth-user", + name: "Imported Client", email: "client@example.com", status: "active", + pipeline_stage: "won", created_at: createdAt, + }); + tables.client_activities.push({ + id: "source-activity", user_id: sourceOwnerUserId, client_id: "source-client", + type: "meeting", title: "Imported Meeting", activity_date: createdAt, created_at: createdAt, + }); + tables.projects.push({ + id: "source-project", user_id: sourceOwnerUserId, client_id: "source-client", + name: "Imported Project", type: "client_project", status: "active", + budget_amount: "1000.05", currency: "try", progress: 25, progress_type: "manual", + revision_quota: 2, cover_image_path: "source/project-cover.png", created_at: createdAt, + }); + tables.daily_logs.push({ + id: "source-daily", user_id: sourceOwnerUserId, log_date: "2026-07-17", + mood_score: 4, energy_score: 3, work_satisfaction_score: 5, + note: "Daily note", created_at: createdAt, + }); + tables.journals.push({ + id: "source-journal", user_id: sourceOwnerUserId, date: "2026-07-17T08:00:00.000Z", + mood: "focused", energy: 4, content: "Legacy journal content", ai_tags: ["focus"], + ai_summary: "Legacy summary", created_at: createdAt, + }); + tables.tasks.push({ + id: "source-task", user_id: sourceOwnerUserId, client_id: "source-client", + project_id: "source-project", source_journal_id: "source-journal", title: "Imported Task", + status: "completed", priority: "high", date: "2026-07-17", + is_public_to_client: true, created_at: createdAt, + }); + tables.calendar_events.push({ + id: "source-event", user_id: sourceOwnerUserId, client_id: "source-client", + project_id: "source-project", task_id: "source-task", title: "Imported Event", + type: "meeting", starts_at: createdAt, ends_at: "2026-07-17T11:00:00.000Z", + created_at: createdAt, + }); + tables.finance_transactions.push({ + id: "source-finance", user_id: sourceOwnerUserId, client_id: "source-client", + project_id: "source-project", type: "income", amount: "12.34", currency: "try", + transaction_date: "2026-07-17", payment_status: "paid", created_at: createdAt, + }); + tables.project_planning_sections.push({ + id: "source-section", user_id: sourceOwnerUserId, project_id: "source-project", + category: "assets", title: "Imported Assets", metadata: { cover: "source/project-cover.png" }, + sort_order: 0, created_at: createdAt, + }); + tables.project_revisions.push({ + id: "source-revision", project_id: "source-project", client_id: "source-client", + requested_by: "legacy-client-auth-user", description: "Imported revision", + status: "completed", created_at: createdAt, + }); + tables.chat_sessions.push({ + id: "source-chat", user_id: sourceOwnerUserId, title: "Imported Chat", created_at: createdAt, + }); + tables.chat_messages.push({ + id: "source-message", session_id: "source-chat", role: "user", + content: "Imported message", context_journal_ids: ["source-journal"], created_at: createdAt, + }); + tables.proposals.push({ + id: "source-proposal", user_id: sourceOwnerUserId, client_id: "source-client", + project_id: "source-project", title: "Imported Proposal", amount: "100.00", + currency: "TRY", status: "accepted", created_at: createdAt, + }); + tables.contracts.push({ + id: "source-contract", user_id: sourceOwnerUserId, proposal_id: "source-proposal", + client_id: "source-client", title: "Imported Contract", status: "active", created_at: createdAt, + }); + tables.invoices.push({ + id: "source-invoice", user_id: sourceOwnerUserId, client_id: "source-client", + project_id: "source-project", invoice_number: "IMPORT-001", amount: "100.00", + tax_rate: "18.50", currency: "TRY", status: "paid", issue_date: "2026-07-17", + created_at: createdAt, + }); + tables.subscriptions.push({ + id: "source-subscription", user_id: sourceOwnerUserId, name: "Imported Hosting", + amount: "50.00", currency: "TRY", billing_cycle: "monthly", status: "active", + created_at: createdAt, + }); + tables.app_settings.push({ + id: "source-settings", user_id: sourceOwnerUserId, timezone: "Europe/Istanbul", + currency: "TRY", ai_provider: "google", ai_model: "gemini-test-model", + api_key: "legacy-plain-text-secret", created_at: createdAt, + }); + tables.document_embeddings.push({ + id: "source-embedding", user_id: sourceOwnerUserId, content: "Archived only", + }); + + return { + format: "neta-supabase-export", + version: 1, + exported_at: createdAt, + source: { owner_user_id: sourceOwnerUserId }, + auth: { users: [{ password: "source-password-secret" }] }, + tables, + storage: { + objects: [ + { + bucket: "avatars", object_path: "source/avatar.png", + source_url: "https://legacy.example/storage/v1/object/public/avatars/source/avatar.png", + local_path: avatarLocalPath, original_name: "avatar.png", mime_type: "image/png", + bytes: avatarBytes.length, sha256: hash(avatarBytes), created_at: createdAt, + }, + { + bucket: "project-assets", object_path: "source/project-cover.png", + local_path: coverLocalPath, original_name: "cover.png", mime_type: "image/png", + bytes: coverBytes.length, sha256: hash(coverBytes), project_id: "source-project", + portal_visible: true, + references: [{ type: "project_cover", project_id: "source-project" }], + created_at: createdAt, + }, + ], + }, + }; +} diff --git a/scripts/restore.mjs b/scripts/restore.mjs index fa89feb..59f0371 100644 --- a/scripts/restore.mjs +++ b/scripts/restore.mjs @@ -12,7 +12,7 @@ if (!args.from) { const targetEnv = { ...process.env, DATA_DIR: args.target || process.env.DATA_DIR, - DATABASE_PATH: undefined, + DATABASE_PATH: args.target ? undefined : process.env.DATABASE_PATH, }; const config = ensureDataLayout(getDataConfig(targetEnv)); @@ -31,13 +31,62 @@ if (fs.existsSync(config.databasePath) && !args.force) { throw new Error(`Target database exists: ${config.databasePath}. Pass --force to overwrite.`); } -fs.copyFileSync(backupDbPath, config.databasePath); +const restoreId = `${process.pid}-${Date.now()}`; +const stagedDatabasePath = `${config.databasePath}.restore-stage-${restoreId}`; +const rollbackDatabasePath = `${config.databasePath}.restore-rollback-${restoreId}`; +const stagedUploadsDir = `${config.uploadsDir}.restore-stage-${restoreId}`; +const rollbackUploadsDir = `${config.uploadsDir}.restore-rollback-${restoreId}`; -if (fs.existsSync(backupUploadsDir)) { - fs.rmSync(config.uploadsDir, { recursive: true, force: true }); - copyDirectory(backupUploadsDir, config.uploadsDir); +let databaseMovedToRollback = false; +let uploadsMovedToRollback = false; +let stagedDatabaseInstalled = false; +let stagedUploadsInstalled = false; + +try { + fs.copyFileSync(backupDbPath, stagedDatabasePath, fs.constants.COPYFILE_EXCL); + if (hashFile(stagedDatabasePath) !== hashFile(backupDbPath)) { + throw new Error("Staged database checksum does not match the verified backup."); + } + fs.mkdirSync(stagedUploadsDir); + if (fs.existsSync(backupUploadsDir)) { + copyDirectory(backupUploadsDir, stagedUploadsDir); + } + + if (fs.existsSync(config.databasePath)) { + fs.renameSync(config.databasePath, rollbackDatabasePath); + databaseMovedToRollback = true; + } + if (fs.existsSync(config.uploadsDir)) { + fs.renameSync(config.uploadsDir, rollbackUploadsDir); + uploadsMovedToRollback = true; + } + + fs.renameSync(stagedDatabasePath, config.databasePath); + stagedDatabaseInstalled = true; + fs.renameSync(stagedUploadsDir, config.uploadsDir); + stagedUploadsInstalled = true; +} catch (error) { + if (stagedDatabaseInstalled && fs.existsSync(config.databasePath)) { + fs.rmSync(config.databasePath, { force: true }); + } + if (stagedUploadsInstalled && fs.existsSync(config.uploadsDir)) { + fs.rmSync(config.uploadsDir, { recursive: true, force: true }); + } + if (databaseMovedToRollback && fs.existsSync(rollbackDatabasePath)) { + fs.renameSync(rollbackDatabasePath, config.databasePath); + } + if (uploadsMovedToRollback && fs.existsSync(rollbackUploadsDir)) { + fs.renameSync(rollbackUploadsDir, config.uploadsDir); + } + throw error; +} finally { + fs.rmSync(stagedDatabasePath, { force: true }); + fs.rmSync(stagedUploadsDir, { recursive: true, force: true }); } +fs.rmSync(rollbackDatabasePath, { force: true }); +fs.rmSync(rollbackUploadsDir, { recursive: true, force: true }); + console.log(`Backup restored from ${backupDir} to ${config.dataDir}`); function parseArgs(values) { @@ -67,10 +116,14 @@ function copyDirectory(sourceDir, targetDir) { const sourcePath = path.join(sourceDir, entry.name); const targetPath = path.join(targetDir, entry.name); - if (entry.isDirectory()) { + if (entry.isSymbolicLink()) { + throw new Error(`Backup contains a symbolic link: ${sourcePath}`); + } else if (entry.isDirectory()) { copyDirectory(sourcePath, targetPath); } else if (entry.isFile()) { fs.copyFileSync(sourcePath, targetPath); + } else { + throw new Error(`Backup contains an unsupported filesystem entry: ${sourcePath}`); } } } @@ -81,6 +134,12 @@ function verifyManifest(rootDir, manifestFile) { } const manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8")); + if ( + (manifest.format !== undefined && manifest.format !== "neta-backup") || + (manifest.version !== undefined && manifest.version !== 1) + ) { + throw new Error("Backup manifest format or version is not supported."); + } if (!Array.isArray(manifest.files) || manifest.files.length === 0) { throw new Error("Backup manifest has no file entries."); } @@ -135,3 +194,7 @@ function collectBackupFiles(rootDir, currentDir) { } return files; } + +function hashFile(filePath) { + return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); +} diff --git a/server/db/migrations/0006_moaning_kitty_pryde.sql b/server/db/migrations/0006_moaning_kitty_pryde.sql new file mode 100644 index 0000000..8f3fa8a --- /dev/null +++ b/server/db/migrations/0006_moaning_kitty_pryde.sql @@ -0,0 +1,15 @@ +CREATE TABLE `user_preferences` ( + `owner_user_id` text PRIMARY KEY NOT NULL, + `timezone` text DEFAULT 'Europe/Istanbul' NOT NULL, + `default_currency` text DEFAULT 'TRY' NOT NULL, + `language` text DEFAULT 'tr' NOT NULL, + `date_format` text DEFAULT 'dd.MM.yyyy' NOT NULL, + `color_mode` text DEFAULT 'system' NOT NULL, + `sidebar_collapsed` integer DEFAULT false NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade, + CONSTRAINT "user_preferences_currency_check" CHECK(length("user_preferences"."default_currency") = 3), + CONSTRAINT "user_preferences_language_check" CHECK("user_preferences"."language" in ('tr', 'en')), + CONSTRAINT "user_preferences_color_mode_check" CHECK("user_preferences"."color_mode" in ('light', 'dark', 'system')) +); diff --git a/server/db/migrations/meta/0006_snapshot.json b/server/db/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..06913d9 --- /dev/null +++ b/server/db/migrations/meta/0006_snapshot.json @@ -0,0 +1,3779 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "1c9ab6a5-0948-44d7-9c65-03e6ea99db34", + "prevId": "7d145639-7631-4442-9241-3c81212d3d9f", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_profiles": { + "name": "app_profiles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "auth_user_id": { + "name": "auth_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disabled": { + "name": "disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "app_profiles_auth_user_id_unique": { + "name": "app_profiles_auth_user_id_unique", + "columns": [ + "auth_user_id" + ], + "isUnique": true + }, + "app_profiles_client_id_unique": { + "name": "app_profiles_client_id_unique", + "columns": [ + "client_id" + ], + "isUnique": true + }, + "app_profiles_role_idx": { + "name": "app_profiles_role_idx", + "columns": [ + "role" + ], + "isUnique": false + } + }, + "foreignKeys": { + "app_profiles_auth_user_id_user_id_fk": { + "name": "app_profiles_auth_user_id_user_id_fk", + "tableFrom": "app_profiles", + "tableTo": "user", + "columnsFrom": [ + "auth_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_setup_state": { + "name": "app_setup_state", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_by": { + "name": "locked_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locked_at": { + "name": "locked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_audit_events": { + "name": "auth_audit_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_user_id": { + "name": "auth_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "auth_audit_events_type_idx": { + "name": "auth_audit_events_type_idx", + "columns": [ + "type" + ], + "isUnique": false + }, + "auth_audit_events_auth_user_id_idx": { + "name": "auth_audit_events_auth_user_id_idx", + "columns": [ + "auth_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "auth_audit_events_auth_user_id_user_id_fk": { + "name": "auth_audit_events_auth_user_id_user_id_fk", + "tableFrom": "auth_audit_events", + "tableTo": "user", + "columnsFrom": [ + "auth_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "portal_invitations": { + "name": "portal_invitations", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "portal_invitations_token_hash_unique": { + "name": "portal_invitations_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + }, + "portal_invitations_client_id_idx": { + "name": "portal_invitations_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "portal_invitations_email_idx": { + "name": "portal_invitations_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "portal_invitations_created_by_user_id_user_id_fk": { + "name": "portal_invitations_created_by_user_id_user_id_fk", + "tableFrom": "portal_invitations", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "calendar_events": { + "name": "calendar_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'focus'" + }, + "starts_at": { + "name": "starts_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ends_at": { + "name": "ends_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "calendar_events_owner_range_idx": { + "name": "calendar_events_owner_range_idx", + "columns": [ + "owner_user_id", + "starts_at" + ], + "isUnique": false + }, + "calendar_events_project_id_idx": { + "name": "calendar_events_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "calendar_events_task_id_idx": { + "name": "calendar_events_task_id_idx", + "columns": [ + "task_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "calendar_events_owner_user_id_user_id_fk": { + "name": "calendar_events_owner_user_id_user_id_fk", + "tableFrom": "calendar_events", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "calendar_events_client_id_clients_id_fk": { + "name": "calendar_events_client_id_clients_id_fk", + "tableFrom": "calendar_events", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "calendar_events_project_id_projects_id_fk": { + "name": "calendar_events_project_id_projects_id_fk", + "tableFrom": "calendar_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "calendar_events_task_id_tasks_id_fk": { + "name": "calendar_events_task_id_tasks_id_fk", + "tableFrom": "calendar_events", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "calendar_events_type_check": { + "name": "calendar_events_type_check", + "value": "\"calendar_events\".\"type\" in ('meeting', 'focus', 'deadline', 'personal', 'finance')" + }, + "calendar_events_time_check": { + "name": "calendar_events_time_check", + "value": "\"calendar_events\".\"ends_at\" is null or \"calendar_events\".\"ends_at\" >= \"calendar_events\".\"starts_at\"" + } + } + }, + "chat_messages": { + "name": "chat_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_journal_entry_ids": { + "name": "context_journal_entry_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "chat_messages_session_created_idx": { + "name": "chat_messages_session_created_idx", + "columns": [ + "session_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_messages_session_id_chat_sessions_id_fk": { + "name": "chat_messages_session_id_chat_sessions_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "chat_messages_role_check": { + "name": "chat_messages_role_check", + "value": "\"chat_messages\".\"role\" in ('system', 'user', 'assistant', 'tool')" + } + } + }, + "chat_sessions": { + "name": "chat_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "chat_sessions_owner_updated_idx": { + "name": "chat_sessions_owner_updated_idx", + "columns": [ + "owner_user_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_sessions_owner_user_id_user_id_fk": { + "name": "chat_sessions_owner_user_id_user_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "client_activities": { + "name": "client_activities", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "activity_date": { + "name": "activity_date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "client_activities_owner_client_idx": { + "name": "client_activities_owner_client_idx", + "columns": [ + "owner_user_id", + "client_id" + ], + "isUnique": false + }, + "client_activities_client_date_idx": { + "name": "client_activities_client_date_idx", + "columns": [ + "client_id", + "activity_date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "client_activities_owner_user_id_user_id_fk": { + "name": "client_activities_owner_user_id_user_id_fk", + "tableFrom": "client_activities", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_activities_client_id_clients_id_fk": { + "name": "client_activities_client_id_clients_id_fk", + "tableFrom": "client_activities", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "client_activities_type_check": { + "name": "client_activities_type_check", + "value": "\"client_activities\".\"type\" in ('note', 'call', 'meeting', 'email')" + } + } + }, + "clients": { + "name": "clients", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_user_id": { + "name": "auth_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "pipeline_stage": { + "name": "pipeline_stage", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'lead'" + }, + "next_follow_up_date": { + "name": "next_follow_up_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "clients_auth_user_id_unique": { + "name": "clients_auth_user_id_unique", + "columns": [ + "auth_user_id" + ], + "isUnique": true + }, + "clients_owner_user_id_idx": { + "name": "clients_owner_user_id_idx", + "columns": [ + "owner_user_id" + ], + "isUnique": false + }, + "clients_owner_status_idx": { + "name": "clients_owner_status_idx", + "columns": [ + "owner_user_id", + "status" + ], + "isUnique": false + }, + "clients_owner_pipeline_idx": { + "name": "clients_owner_pipeline_idx", + "columns": [ + "owner_user_id", + "pipeline_stage" + ], + "isUnique": false + }, + "clients_next_follow_up_date_idx": { + "name": "clients_next_follow_up_date_idx", + "columns": [ + "next_follow_up_date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "clients_owner_user_id_user_id_fk": { + "name": "clients_owner_user_id_user_id_fk", + "tableFrom": "clients", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "clients_auth_user_id_user_id_fk": { + "name": "clients_auth_user_id_user_id_fk", + "tableFrom": "clients", + "tableTo": "user", + "columnsFrom": [ + "auth_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "clients_status_check": { + "name": "clients_status_check", + "value": "\"clients\".\"status\" in ('active', 'paused', 'archived')" + }, + "clients_pipeline_stage_check": { + "name": "clients_pipeline_stage_check", + "value": "\"clients\".\"pipeline_stage\" in ('lead', 'contacted', 'proposal_sent', 'won', 'lost')" + } + } + }, + "contracts": { + "name": "contracts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "proposal_id": { + "name": "proposal_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "signed_at": { + "name": "signed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "contracts_owner_status_idx": { + "name": "contracts_owner_status_idx", + "columns": [ + "owner_user_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "contracts_owner_user_id_user_id_fk": { + "name": "contracts_owner_user_id_user_id_fk", + "tableFrom": "contracts", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "contracts_proposal_id_proposals_id_fk": { + "name": "contracts_proposal_id_proposals_id_fk", + "tableFrom": "contracts", + "tableTo": "proposals", + "columnsFrom": [ + "proposal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "contracts_client_id_clients_id_fk": { + "name": "contracts_client_id_clients_id_fk", + "tableFrom": "contracts", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "contracts_status_check": { + "name": "contracts_status_check", + "value": "\"contracts\".\"status\" in ('draft', 'active', 'completed', 'cancelled')" + } + } + }, + "finance_transactions": { + "name": "finance_transactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_minor": { + "name": "amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "transaction_date": { + "name": "transaction_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'planned'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "finance_transactions_owner_date_idx": { + "name": "finance_transactions_owner_date_idx", + "columns": [ + "owner_user_id", + "transaction_date" + ], + "isUnique": false + }, + "finance_transactions_owner_type_idx": { + "name": "finance_transactions_owner_type_idx", + "columns": [ + "owner_user_id", + "type" + ], + "isUnique": false + }, + "finance_transactions_client_id_idx": { + "name": "finance_transactions_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "finance_transactions_project_id_idx": { + "name": "finance_transactions_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "finance_transactions_owner_user_id_user_id_fk": { + "name": "finance_transactions_owner_user_id_user_id_fk", + "tableFrom": "finance_transactions", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "finance_transactions_client_id_clients_id_fk": { + "name": "finance_transactions_client_id_clients_id_fk", + "tableFrom": "finance_transactions", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "finance_transactions_project_id_projects_id_fk": { + "name": "finance_transactions_project_id_projects_id_fk", + "tableFrom": "finance_transactions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "finance_transactions_type_check": { + "name": "finance_transactions_type_check", + "value": "\"finance_transactions\".\"type\" in ('income', 'expense')" + }, + "finance_transactions_amount_check": { + "name": "finance_transactions_amount_check", + "value": "\"finance_transactions\".\"amount_minor\" >= 0" + }, + "finance_transactions_payment_status_check": { + "name": "finance_transactions_payment_status_check", + "value": "\"finance_transactions\".\"payment_status\" in ('planned', 'pending', 'paid', 'cancelled')" + }, + "finance_transactions_currency_check": { + "name": "finance_transactions_currency_check", + "value": "length(\"finance_transactions\".\"currency\") = 3" + } + } + }, + "invoices": { + "name": "invoices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_minor": { + "name": "amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "tax_basis_points": { + "name": "tax_basis_points", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'TRY'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "issue_date": { + "name": "issue_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_at": { + "name": "paid_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "invoices_owner_number_unique": { + "name": "invoices_owner_number_unique", + "columns": [ + "owner_user_id", + "invoice_number" + ], + "isUnique": true + }, + "invoices_owner_status_idx": { + "name": "invoices_owner_status_idx", + "columns": [ + "owner_user_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invoices_owner_user_id_user_id_fk": { + "name": "invoices_owner_user_id_user_id_fk", + "tableFrom": "invoices", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoices_client_id_clients_id_fk": { + "name": "invoices_client_id_clients_id_fk", + "tableFrom": "invoices", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "invoices_project_id_projects_id_fk": { + "name": "invoices_project_id_projects_id_fk", + "tableFrom": "invoices", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "invoices_status_check": { + "name": "invoices_status_check", + "value": "\"invoices\".\"status\" in ('draft', 'sent', 'paid', 'overdue', 'cancelled')" + }, + "invoices_amount_check": { + "name": "invoices_amount_check", + "value": "\"invoices\".\"amount_minor\" >= 0" + }, + "invoices_tax_check": { + "name": "invoices_tax_check", + "value": "\"invoices\".\"tax_basis_points\" between 0 and 10000" + } + } + }, + "journal_entries": { + "name": "journal_entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entry_date": { + "name": "entry_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mood_score": { + "name": "mood_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "energy_score": { + "name": "energy_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "work_satisfaction_score": { + "name": "work_satisfaction_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mood_label": { + "name": "mood_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_ai_metadata": { + "name": "legacy_ai_metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "journal_entries_owner_date_unique": { + "name": "journal_entries_owner_date_unique", + "columns": [ + "owner_user_id", + "entry_date" + ], + "isUnique": true + }, + "journal_entries_owner_date_idx": { + "name": "journal_entries_owner_date_idx", + "columns": [ + "owner_user_id", + "entry_date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "journal_entries_owner_user_id_user_id_fk": { + "name": "journal_entries_owner_user_id_user_id_fk", + "tableFrom": "journal_entries", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "journal_entries_mood_score_check": { + "name": "journal_entries_mood_score_check", + "value": "\"journal_entries\".\"mood_score\" is null or \"journal_entries\".\"mood_score\" between 1 and 5" + }, + "journal_entries_energy_score_check": { + "name": "journal_entries_energy_score_check", + "value": "\"journal_entries\".\"energy_score\" is null or \"journal_entries\".\"energy_score\" between 1 and 5" + }, + "journal_entries_work_score_check": { + "name": "journal_entries_work_score_check", + "value": "\"journal_entries\".\"work_satisfaction_score\" is null or \"journal_entries\".\"work_satisfaction_score\" between 1 and 5" + } + } + }, + "project_planning_sections": { + "name": "project_planning_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "project_planning_sections_owner_idx": { + "name": "project_planning_sections_owner_idx", + "columns": [ + "owner_user_id" + ], + "isUnique": false + }, + "project_planning_sections_project_order_idx": { + "name": "project_planning_sections_project_order_idx", + "columns": [ + "project_id", + "sort_order" + ], + "isUnique": false + } + }, + "foreignKeys": { + "project_planning_sections_owner_user_id_user_id_fk": { + "name": "project_planning_sections_owner_user_id_user_id_fk", + "tableFrom": "project_planning_sections", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_planning_sections_project_id_projects_id_fk": { + "name": "project_planning_sections_project_id_projects_id_fk", + "tableFrom": "project_planning_sections", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_planning_sections_category_check": { + "name": "project_planning_sections_category_check", + "value": "\"project_planning_sections\".\"category\" in ('overview', 'problem', 'goal', 'audience', 'scope', 'design_system', 'color_palette', 'typography', 'assets', 'notes')" + } + } + }, + "project_revisions": { + "name": "project_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "project_revisions_owner_project_idx": { + "name": "project_revisions_owner_project_idx", + "columns": [ + "owner_user_id", + "project_id" + ], + "isUnique": false + }, + "project_revisions_client_project_idx": { + "name": "project_revisions_client_project_idx", + "columns": [ + "client_id", + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "project_revisions_owner_user_id_user_id_fk": { + "name": "project_revisions_owner_user_id_user_id_fk", + "tableFrom": "project_revisions", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_revisions_project_id_projects_id_fk": { + "name": "project_revisions_project_id_projects_id_fk", + "tableFrom": "project_revisions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_revisions_client_id_clients_id_fk": { + "name": "project_revisions_client_id_clients_id_fk", + "tableFrom": "project_revisions", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_revisions_requested_by_user_id_user_id_fk": { + "name": "project_revisions_requested_by_user_id_user_id_fk", + "tableFrom": "project_revisions", + "tableTo": "user", + "columnsFrom": [ + "requested_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_revisions_status_check": { + "name": "project_revisions_status_check", + "value": "\"project_revisions\".\"status\" in ('pending', 'in_progress', 'completed', 'rejected')" + } + } + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'client_project'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'planning'" + }, + "start_date": { + "name": "start_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "budget_amount_minor": { + "name": "budget_amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "progress_type": { + "name": "progress_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "revision_quota": { + "name": "revision_quota", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "legacy_cover_image_path": { + "name": "legacy_cover_image_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_image_alt": { + "name": "cover_image_alt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "projects_owner_user_id_idx": { + "name": "projects_owner_user_id_idx", + "columns": [ + "owner_user_id" + ], + "isUnique": false + }, + "projects_owner_status_idx": { + "name": "projects_owner_status_idx", + "columns": [ + "owner_user_id", + "status" + ], + "isUnique": false + }, + "projects_client_id_idx": { + "name": "projects_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "projects_due_date_idx": { + "name": "projects_due_date_idx", + "columns": [ + "due_date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "projects_owner_user_id_user_id_fk": { + "name": "projects_owner_user_id_user_id_fk", + "tableFrom": "projects", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_client_id_clients_id_fk": { + "name": "projects_client_id_clients_id_fk", + "tableFrom": "projects", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "projects_type_check": { + "name": "projects_type_check", + "value": "\"projects\".\"type\" in ('client_project', 'side_project')" + }, + "projects_status_check": { + "name": "projects_status_check", + "value": "\"projects\".\"status\" in ('planning', 'active', 'paused', 'completed', 'cancelled')" + }, + "projects_progress_check": { + "name": "projects_progress_check", + "value": "\"projects\".\"progress\" between 0 and 100" + }, + "projects_revision_quota_check": { + "name": "projects_revision_quota_check", + "value": "\"projects\".\"revision_quota\" >= 0" + }, + "projects_budget_check": { + "name": "projects_budget_check", + "value": "\"projects\".\"budget_amount_minor\" is null or \"projects\".\"budget_amount_minor\" >= 0" + }, + "projects_currency_check": { + "name": "projects_currency_check", + "value": "length(\"projects\".\"currency\") = 3" + } + } + }, + "proposals": { + "name": "proposals", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount_minor": { + "name": "amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'TRY'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "valid_until": { + "name": "valid_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "proposals_owner_status_idx": { + "name": "proposals_owner_status_idx", + "columns": [ + "owner_user_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "proposals_owner_user_id_user_id_fk": { + "name": "proposals_owner_user_id_user_id_fk", + "tableFrom": "proposals", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proposals_client_id_clients_id_fk": { + "name": "proposals_client_id_clients_id_fk", + "tableFrom": "proposals", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "proposals_project_id_projects_id_fk": { + "name": "proposals_project_id_projects_id_fk", + "tableFrom": "proposals", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "proposals_status_check": { + "name": "proposals_status_check", + "value": "\"proposals\".\"status\" in ('draft', 'sent', 'accepted', 'rejected')" + }, + "proposals_amount_check": { + "name": "proposals_amount_check", + "value": "\"proposals\".\"amount_minor\" >= 0" + } + } + }, + "subscriptions": { + "name": "subscriptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_minor": { + "name": "amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'TRY'" + }, + "billing_cycle": { + "name": "billing_cycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'monthly'" + }, + "next_billing_date": { + "name": "next_billing_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "subscriptions_owner_status_idx": { + "name": "subscriptions_owner_status_idx", + "columns": [ + "owner_user_id", + "status" + ], + "isUnique": false + }, + "subscriptions_next_billing_date_idx": { + "name": "subscriptions_next_billing_date_idx", + "columns": [ + "next_billing_date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "subscriptions_owner_user_id_user_id_fk": { + "name": "subscriptions_owner_user_id_user_id_fk", + "tableFrom": "subscriptions", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "subscriptions_cycle_check": { + "name": "subscriptions_cycle_check", + "value": "\"subscriptions\".\"billing_cycle\" in ('weekly', 'monthly', 'yearly')" + }, + "subscriptions_status_check": { + "name": "subscriptions_status_check", + "value": "\"subscriptions\".\"status\" in ('active', 'cancelled')" + }, + "subscriptions_amount_check": { + "name": "subscriptions_amount_check", + "value": "\"subscriptions\".\"amount_minor\" >= 0" + } + } + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_journal_entry_id": { + "name": "source_journal_entry_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'todo'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'medium'" + }, + "scheduled_date": { + "name": "scheduled_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_at": { + "name": "due_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimated_minutes": { + "name": "estimated_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actual_minutes": { + "name": "actual_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ai_generated": { + "name": "ai_generated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_public_to_client": { + "name": "is_public_to_client", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "tasks_owner_user_id_idx": { + "name": "tasks_owner_user_id_idx", + "columns": [ + "owner_user_id" + ], + "isUnique": false + }, + "tasks_owner_status_idx": { + "name": "tasks_owner_status_idx", + "columns": [ + "owner_user_id", + "status" + ], + "isUnique": false + }, + "tasks_project_id_idx": { + "name": "tasks_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "tasks_client_id_idx": { + "name": "tasks_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "tasks_due_at_idx": { + "name": "tasks_due_at_idx", + "columns": [ + "due_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_owner_user_id_user_id_fk": { + "name": "tasks_owner_user_id_user_id_fk", + "tableFrom": "tasks", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_client_id_clients_id_fk": { + "name": "tasks_client_id_clients_id_fk", + "tableFrom": "tasks", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_source_journal_entry_id_journal_entries_id_fk": { + "name": "tasks_source_journal_entry_id_journal_entries_id_fk", + "tableFrom": "tasks", + "tableTo": "journal_entries", + "columnsFrom": [ + "source_journal_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "tasks_status_check": { + "name": "tasks_status_check", + "value": "\"tasks\".\"status\" in ('todo', 'in_progress', 'done', 'cancelled')" + }, + "tasks_priority_check": { + "name": "tasks_priority_check", + "value": "\"tasks\".\"priority\" in ('low', 'medium', 'high', 'urgent')" + }, + "tasks_estimated_minutes_check": { + "name": "tasks_estimated_minutes_check", + "value": "\"tasks\".\"estimated_minutes\" is null or \"tasks\".\"estimated_minutes\" >= 0" + }, + "tasks_actual_minutes_check": { + "name": "tasks_actual_minutes_check", + "value": "\"tasks\".\"actual_minutes\" is null or \"tasks\".\"actual_minutes\" >= 0" + } + } + }, + "runtime_checks": { + "name": "runtime_checks", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "runtime_events": { + "name": "runtime_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_ai_settings": { + "name": "user_ai_settings", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'gemini'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_ai_settings_owner_user_id_user_id_fk": { + "name": "user_ai_settings_owner_user_id_user_id_fk", + "tableFrom": "user_ai_settings", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "user_ai_settings_provider_check": { + "name": "user_ai_settings_provider_check", + "value": "\"user_ai_settings\".\"provider\" in ('gemini', 'openai', 'groq', 'ollama')" + } + } + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Europe/Istanbul'" + }, + "default_currency": { + "name": "default_currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'TRY'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'tr'" + }, + "date_format": { + "name": "date_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'dd.MM.yyyy'" + }, + "color_mode": { + "name": "color_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "sidebar_collapsed": { + "name": "sidebar_collapsed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_owner_user_id_user_id_fk": { + "name": "user_preferences_owner_user_id_user_id_fk", + "tableFrom": "user_preferences", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "user_preferences_currency_check": { + "name": "user_preferences_currency_check", + "value": "length(\"user_preferences\".\"default_currency\") = 3" + }, + "user_preferences_language_check": { + "name": "user_preferences_language_check", + "value": "\"user_preferences\".\"language\" in ('tr', 'en')" + }, + "user_preferences_color_mode_check": { + "name": "user_preferences_color_mode_check", + "value": "\"user_preferences\".\"color_mode\" in ('light', 'dark', 'system')" + } + } + }, + "files": { + "name": "files", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by_user_id": { + "name": "uploaded_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_user_id": { + "name": "auth_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'private'" + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "files_storage_path_unique": { + "name": "files_storage_path_unique", + "columns": [ + "storage_path" + ], + "isUnique": true + }, + "files_owner_kind_idx": { + "name": "files_owner_kind_idx", + "columns": [ + "owner_user_id", + "kind" + ], + "isUnique": false + }, + "files_auth_user_id_idx": { + "name": "files_auth_user_id_idx", + "columns": [ + "auth_user_id" + ], + "isUnique": false + }, + "files_project_id_idx": { + "name": "files_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "files_sha256_idx": { + "name": "files_sha256_idx", + "columns": [ + "sha256" + ], + "isUnique": false + } + }, + "foreignKeys": { + "files_owner_user_id_user_id_fk": { + "name": "files_owner_user_id_user_id_fk", + "tableFrom": "files", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "files_uploaded_by_user_id_user_id_fk": { + "name": "files_uploaded_by_user_id_user_id_fk", + "tableFrom": "files", + "tableTo": "user", + "columnsFrom": [ + "uploaded_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "files_auth_user_id_user_id_fk": { + "name": "files_auth_user_id_user_id_fk", + "tableFrom": "files", + "tableTo": "user", + "columnsFrom": [ + "auth_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "files_project_id_projects_id_fk": { + "name": "files_project_id_projects_id_fk", + "tableFrom": "files", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "files_kind_check": { + "name": "files_kind_check", + "value": "\"files\".\"kind\" in ('avatar', 'branding_logo', 'branding_icon', 'project_asset')" + }, + "files_visibility_check": { + "name": "files_visibility_check", + "value": "\"files\".\"visibility\" in ('private', 'portal', 'public_branding')" + }, + "files_byte_size_check": { + "name": "files_byte_size_check", + "value": "\"files\".\"byte_size\" > 0" + }, + "files_sha256_check": { + "name": "files_sha256_check", + "value": "length(\"files\".\"sha256\") = 64" + }, + "files_storage_path_check": { + "name": "files_storage_path_check", + "value": "\"files\".\"storage_path\" not like '/%' and instr(\"files\".\"storage_path\", '..') = 0" + }, + "files_resource_check": { + "name": "files_resource_check", + "value": "(\n (\"files\".\"kind\" = 'avatar' and \"files\".\"auth_user_id\" is not null and \"files\".\"project_id\" is null and \"files\".\"visibility\" = 'private')\n or (\"files\".\"kind\" in ('branding_logo', 'branding_icon') and \"files\".\"auth_user_id\" is null and \"files\".\"project_id\" is null and \"files\".\"visibility\" = 'public_branding')\n or (\"files\".\"kind\" = 'project_asset' and \"files\".\"auth_user_id\" is null and \"files\".\"project_id\" is not null and \"files\".\"visibility\" in ('private', 'portal'))\n )" + } + } + }, + "instance_branding": { + "name": "instance_branding", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "application_name": { + "name": "application_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Neta'" + }, + "short_name": { + "name": "short_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Neta'" + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#C81E1E'" + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#E6EDF5'" + }, + "light_logo_file_id": { + "name": "light_logo_file_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dark_logo_file_id": { + "name": "dark_logo_file_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_file_id": { + "name": "icon_file_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_color_mode": { + "name": "default_color_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "radius_scale": { + "name": "radius_scale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "organization_name": { + "name": "organization_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "support_email": { + "name": "support_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "portal_welcome_text": { + "name": "portal_welcome_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "portal_footer_text": { + "name": "portal_footer_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "instance_branding_owner_unique": { + "name": "instance_branding_owner_unique", + "columns": [ + "owner_user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "instance_branding_owner_user_id_user_id_fk": { + "name": "instance_branding_owner_user_id_user_id_fk", + "tableFrom": "instance_branding", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "instance_branding_light_logo_file_id_files_id_fk": { + "name": "instance_branding_light_logo_file_id_files_id_fk", + "tableFrom": "instance_branding", + "tableTo": "files", + "columnsFrom": [ + "light_logo_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "instance_branding_dark_logo_file_id_files_id_fk": { + "name": "instance_branding_dark_logo_file_id_files_id_fk", + "tableFrom": "instance_branding", + "tableTo": "files", + "columnsFrom": [ + "dark_logo_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "instance_branding_icon_file_id_files_id_fk": { + "name": "instance_branding_icon_file_id_files_id_fk", + "tableFrom": "instance_branding", + "tableTo": "files", + "columnsFrom": [ + "icon_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "instance_branding_updated_by_user_id_user_id_fk": { + "name": "instance_branding_updated_by_user_id_user_id_fk", + "tableFrom": "instance_branding", + "tableTo": "user", + "columnsFrom": [ + "updated_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "instance_branding_id_check": { + "name": "instance_branding_id_check", + "value": "\"instance_branding\".\"id\" = 'default'" + }, + "instance_branding_primary_color_check": { + "name": "instance_branding_primary_color_check", + "value": "\"instance_branding\".\"primary_color\" glob '#[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]'" + }, + "instance_branding_accent_color_check": { + "name": "instance_branding_accent_color_check", + "value": "\"instance_branding\".\"accent_color\" glob '#[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]'" + }, + "instance_branding_color_mode_check": { + "name": "instance_branding_color_mode_check", + "value": "\"instance_branding\".\"default_color_mode\" in ('light', 'dark', 'system')" + }, + "instance_branding_radius_scale_check": { + "name": "instance_branding_radius_scale_check", + "value": "\"instance_branding\".\"radius_scale\" in ('compact', 'default', 'soft')" + } + } + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/server/db/migrations/meta/_journal.json b/server/db/migrations/meta/_journal.json index c8ccb11..0941571 100644 --- a/server/db/migrations/meta/_journal.json +++ b/server/db/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1784234752708, "tag": "0005_brief_black_bolt", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1784266217938, + "tag": "0006_moaning_kitty_pryde", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/db/schema/settings.ts b/server/db/schema/settings.ts index c809428..befee90 100644 --- a/server/db/schema/settings.ts +++ b/server/db/schema/settings.ts @@ -1,5 +1,5 @@ import { sql } from "drizzle-orm"; -import { check, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { check, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { user } from "./auth"; export type AiProvider = "gemini" | "openai" | "groq" | "ollama"; @@ -23,3 +23,25 @@ export const userAiSettings = sqliteTable( ), ], ); + +export const userPreferences = sqliteTable( + "user_preferences", + { + ownerUserId: text("owner_user_id") + .primaryKey() + .references(() => user.id, { onDelete: "cascade" }), + timezone: text("timezone").default("Europe/Istanbul").notNull(), + defaultCurrency: text("default_currency").default("TRY").notNull(), + language: text("language").default("tr").notNull(), + dateFormat: text("date_format").default("dd.MM.yyyy").notNull(), + colorMode: text("color_mode").default("system").notNull(), + sidebarCollapsed: integer("sidebar_collapsed", { mode: "boolean" }).default(false).notNull(), + createdAt: text("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(), + updatedAt: text("updated_at").default(sql`CURRENT_TIMESTAMP`).notNull(), + }, + (table) => [ + check("user_preferences_currency_check", sql`length(${table.defaultCurrency}) = 3`), + check("user_preferences_language_check", sql`${table.language} in ('tr', 'en')`), + check("user_preferences_color_mode_check", sql`${table.colorMode} in ('light', 'dark', 'system')`), + ], +);