import { Injectable } from "@nestjs/common"; import { Prisma, PrismaClient, ProjectStatus, TaskPriority, TaskStatus } from "@prisma/client"; import { PrismaService } from "../prisma/prisma.service"; import type { ImportProjectDto, ImportResponseDto, ImportTaskDto } from "./dto"; interface TaskStatusMapping { status: TaskStatus; issue: string | null; } interface TaskPriorityMapping { priority: TaskPriority; issue: string | null; } interface ProjectStatusMapping { status: ProjectStatus; issue: string | null; } @Injectable() export class ImportService { constructor(private readonly prisma: PrismaService) {} async importTasks( workspaceId: string, userId: string, taskPayload: ImportTaskDto[] ): Promise { const errors: string[] = []; let imported = 0; let skipped = 0; const importTimestamp = new Date().toISOString(); const seenBrainTaskIds = new Set(); const domainIdBySlug = new Map(); const projectIdByBrainId = new Map(); await this.prisma.withWorkspaceContext(userId, workspaceId, async (tx: PrismaClient) => { for (const [index, task] of taskPayload.entries()) { const brainId = task.id.trim(); if (seenBrainTaskIds.has(brainId)) { skipped += 1; errors.push(`task ${brainId}: duplicate item in request body`); continue; } seenBrainTaskIds.add(brainId); try { const existingTask = await tx.task.findFirst({ where: { workspaceId, metadata: { path: ["brainId"], equals: brainId, }, }, select: { id: true }, }); if (existingTask) { skipped += 1; continue; } const mappedStatus = this.mapTaskStatus(task.status ?? null); if (mappedStatus.issue) { errors.push(`task ${brainId}: ${mappedStatus.issue}`); } const mappedPriority = this.mapTaskPriority(task.priority ?? null); if (mappedPriority.issue) { errors.push(`task ${brainId}: ${mappedPriority.issue}`); } const projectBrainId = task.project?.trim() ? task.project.trim() : null; const projectId = await this.resolveProjectId( tx, workspaceId, projectBrainId, projectIdByBrainId, brainId, errors ); const domainId = await this.resolveDomainId( tx, workspaceId, task.domain ?? null, importTimestamp, domainIdBySlug ); const createdAt = this.normalizeDate(task.created ?? null, `task ${brainId}.created`, errors) ?? new Date(); const updatedAt = this.normalizeDate(task.updated ?? null, `task ${brainId}.updated`, errors) ?? createdAt; const dueDate = this.normalizeDate(task.due ?? null, `task ${brainId}.due`, errors); const completedAt = mappedStatus.status === TaskStatus.COMPLETED ? updatedAt : null; const metadata = this.asJsonValue({ source: "jarvis-brain", brainId, brainDomain: task.domain ?? null, brainProjectId: projectBrainId, rawStatus: task.status ?? null, rawPriority: task.priority ?? null, related: task.related ?? [], blocks: task.blocks ?? [], blockedBy: task.blocked_by ?? [], assignee: task.assignee ?? null, progress: task.progress ?? null, notes: task.notes ?? null, notesNonTechnical: task.notes_nontechnical ?? null, importedAt: importTimestamp, }); await tx.task.create({ data: { workspaceId, title: task.title, description: task.notes ?? null, status: mappedStatus.status, priority: mappedPriority.priority, dueDate, creatorId: userId, projectId, domainId, metadata, createdAt, updatedAt, completedAt, }, }); imported += 1; } catch (error) { skipped += 1; errors.push( `task ${brainId || `index-${String(index)}`}: failed to import: ${this.getErrorMessage(error)}` ); } } }); return { imported, skipped, errors, }; } async importProjects( workspaceId: string, userId: string, projectPayload: ImportProjectDto[] ): Promise { const errors: string[] = []; let imported = 0; let skipped = 0; const importTimestamp = new Date().toISOString(); const seenBrainProjectIds = new Set(); const domainIdBySlug = new Map(); await this.prisma.withWorkspaceContext(userId, workspaceId, async (tx: PrismaClient) => { for (const [index, project] of projectPayload.entries()) { const brainId = project.id.trim(); if (seenBrainProjectIds.has(brainId)) { skipped += 1; errors.push(`project ${brainId}: duplicate item in request body`); continue; } seenBrainProjectIds.add(brainId); try { const existingProject = await tx.project.findFirst({ where: { workspaceId, metadata: { path: ["brainId"], equals: brainId, }, }, select: { id: true }, }); if (existingProject) { skipped += 1; continue; } const mappedStatus = this.mapProjectStatus(project.status ?? null); if (mappedStatus.issue) { errors.push(`project ${brainId}: ${mappedStatus.issue}`); } const domainId = await this.resolveDomainId( tx, workspaceId, project.domain ?? null, importTimestamp, domainIdBySlug ); const createdAt = this.normalizeDate(project.created ?? null, `project ${brainId}.created`, errors) ?? new Date(); const updatedAt = this.normalizeDate(project.updated ?? null, `project ${brainId}.updated`, errors) ?? createdAt; const startDate = this.normalizeDate( project.created ?? null, `project ${brainId}.startDate`, errors ); const endDate = this.normalizeDate( project.target_date ?? null, `project ${brainId}.target_date`, errors ); const metadata = this.asJsonValue({ source: "jarvis-brain", brainId, brainDomain: project.domain ?? null, rawStatus: project.status ?? null, rawPriority: project.priority ?? null, progress: project.progress ?? null, repo: project.repo ?? null, branch: project.branch ?? null, currentMilestone: project.current_milestone ?? null, nextMilestone: project.next_milestone ?? null, blocker: project.blocker ?? null, owner: project.owner ?? null, docsPath: project.docs_path ?? null, targetDate: project.target_date ?? null, notes: project.notes ?? null, notesNonTechnical: project.notes_nontechnical ?? null, parent: project.parent ?? null, importedAt: importTimestamp, }); await tx.project.create({ data: { workspaceId, name: project.name, description: project.description ?? null, status: mappedStatus.status, startDate, endDate, creatorId: userId, domainId, metadata, createdAt, updatedAt, }, }); imported += 1; } catch (error) { skipped += 1; errors.push( `project ${brainId || `index-${String(index)}`}: failed to import: ${this.getErrorMessage(error)}` ); } } }); return { imported, skipped, errors, }; } private async resolveProjectId( tx: PrismaClient, workspaceId: string, projectBrainId: string | null, projectIdByBrainId: Map, taskBrainId: string, errors: string[] ): Promise { if (!projectBrainId) { return null; } if (projectIdByBrainId.has(projectBrainId)) { return projectIdByBrainId.get(projectBrainId) ?? null; } const existingProject = await tx.project.findFirst({ where: { workspaceId, metadata: { path: ["brainId"], equals: projectBrainId, }, }, select: { id: true }, }); if (!existingProject) { projectIdByBrainId.set(projectBrainId, null); errors.push(`task ${taskBrainId}: referenced project "${projectBrainId}" not found`); return null; } projectIdByBrainId.set(projectBrainId, existingProject.id); return existingProject.id; } private async resolveDomainId( tx: PrismaClient, workspaceId: string, rawDomain: string | null, importTimestamp: string, domainIdBySlug: Map ): Promise { const domainSlug = this.normalizeDomain(rawDomain); if (!domainSlug) { return null; } const cachedId = domainIdBySlug.get(domainSlug); if (cachedId) { return cachedId; } const existingDomain = await tx.domain.findUnique({ where: { workspaceId_slug: { workspaceId, slug: domainSlug, }, }, select: { id: true }, }); if (existingDomain) { domainIdBySlug.set(domainSlug, existingDomain.id); return existingDomain.id; } const trimmedDomainName = rawDomain?.trim(); const domainName = trimmedDomainName && trimmedDomainName.length > 0 ? trimmedDomainName : domainSlug; const createdDomain = await tx.domain.create({ data: { workspaceId, slug: domainSlug, name: domainName, metadata: this.asJsonValue({ source: "jarvis-brain", brainId: domainName, sourceValues: [domainName], importedAt: importTimestamp, }), }, select: { id: true }, }); domainIdBySlug.set(domainSlug, createdDomain.id); return createdDomain.id; } private normalizeKey(value: string | null | undefined): string { return value?.trim().toLowerCase() ?? ""; } private mapTaskStatus(rawStatus: string | null): TaskStatusMapping { const statusKey = this.normalizeKey(rawStatus); switch (statusKey) { case "done": return { status: TaskStatus.COMPLETED, issue: null }; case "in-progress": return { status: TaskStatus.IN_PROGRESS, issue: null }; case "backlog": case "pending": case "scheduled": case "not-started": case "planned": return { status: TaskStatus.NOT_STARTED, issue: null }; case "blocked": case "on-hold": return { status: TaskStatus.PAUSED, issue: null }; case "cancelled": return { status: TaskStatus.ARCHIVED, issue: null }; default: return { status: TaskStatus.NOT_STARTED, issue: `Unknown task status "${rawStatus ?? "null"}" mapped to NOT_STARTED`, }; } } private mapTaskPriority(rawPriority: string | null): TaskPriorityMapping { const priorityKey = this.normalizeKey(rawPriority); switch (priorityKey) { case "critical": case "high": return { priority: TaskPriority.HIGH, issue: null }; case "medium": return { priority: TaskPriority.MEDIUM, issue: null }; case "low": return { priority: TaskPriority.LOW, issue: null }; default: return { priority: TaskPriority.MEDIUM, issue: `Unknown task priority "${rawPriority ?? "null"}" mapped to MEDIUM`, }; } } private mapProjectStatus(rawStatus: string | null): ProjectStatusMapping { const statusKey = this.normalizeKey(rawStatus); switch (statusKey) { case "active": case "in-progress": return { status: ProjectStatus.ACTIVE, issue: null }; case "backlog": case "planning": return { status: ProjectStatus.PLANNING, issue: null }; case "paused": case "blocked": return { status: ProjectStatus.PAUSED, issue: null }; case "archived": case "maintenance": return { status: ProjectStatus.ARCHIVED, issue: null }; default: return { status: ProjectStatus.PLANNING, issue: `Unknown project status "${rawStatus ?? "null"}" mapped to PLANNING`, }; } } private normalizeDomain(rawDomain: string | null | undefined): string | null { if (!rawDomain) { return null; } const trimmed = rawDomain.trim(); if (trimmed.length === 0) { return null; } const slug = trimmed .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); return slug.length > 0 ? slug : null; } private normalizeDate(rawValue: string | null, context: string, errors: string[]): Date | null { if (!rawValue) { return null; } const trimmed = rawValue.trim(); if (trimmed.length === 0) { return null; } const value = /^\d{4}-\d{2}-\d{2}$/.test(trimmed) ? `${trimmed}T00:00:00.000Z` : trimmed; const parsedDate = new Date(value); if (Number.isNaN(parsedDate.getTime())) { errors.push(`${context}: invalid date "${rawValue}"`); return null; } return parsedDate; } private asJsonValue(value: Record): Prisma.InputJsonValue { return value as Prisma.InputJsonValue; } private getErrorMessage(error: unknown): string { if (error instanceof Error) { return error.message; } return String(error); } }