From 82a0eee2e16a9dfc6e380a237ccd740f78768110 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 28 Feb 2026 13:43:13 -0600 Subject: [PATCH] feat(api): add admin bulk import endpoints for tasks and projects --- apps/api/src/app.module.ts | 2 + apps/api/src/import/dto/import-project.dto.ts | 89 ++++ .../api/src/import/dto/import-response.dto.ts | 5 + apps/api/src/import/dto/import-task.dto.ts | 76 +++ apps/api/src/import/dto/index.ts | 3 + apps/api/src/import/import.controller.ts | 33 ++ apps/api/src/import/import.module.ts | 13 + apps/api/src/import/import.service.spec.ts | 251 +++++++++ apps/api/src/import/import.service.ts | 496 ++++++++++++++++++ 9 files changed, 968 insertions(+) create mode 100644 apps/api/src/import/dto/import-project.dto.ts create mode 100644 apps/api/src/import/dto/import-response.dto.ts create mode 100644 apps/api/src/import/dto/import-task.dto.ts create mode 100644 apps/api/src/import/dto/index.ts create mode 100644 apps/api/src/import/import.controller.ts create mode 100644 apps/api/src/import/import.module.ts create mode 100644 apps/api/src/import/import.service.spec.ts create mode 100644 apps/api/src/import/import.service.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 1ae7359..a9df914 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -45,6 +45,7 @@ import { PersonalitiesModule } from "./personalities/personalities.module"; import { WorkspacesModule } from "./workspaces/workspaces.module"; import { AdminModule } from "./admin/admin.module"; import { TeamsModule } from "./teams/teams.module"; +import { ImportModule } from "./import/import.module"; import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor"; @Module({ @@ -113,6 +114,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce WorkspacesModule, AdminModule, TeamsModule, + ImportModule, ], controllers: [AppController, CsrfController], providers: [ diff --git a/apps/api/src/import/dto/import-project.dto.ts b/apps/api/src/import/dto/import-project.dto.ts new file mode 100644 index 0000000..42a4b1e --- /dev/null +++ b/apps/api/src/import/dto/import-project.dto.ts @@ -0,0 +1,89 @@ +import { IsNumber, IsOptional, IsString, MaxLength, MinLength } from "class-validator"; + +/** + * DTO for a single jarvis-brain project record. + * This matches the project object shape consumed by scripts/migrate-brain.ts. + */ +export class ImportProjectDto { + @IsString({ message: "id must be a string" }) + @MinLength(1, { message: "id must not be empty" }) + @MaxLength(255, { message: "id must not exceed 255 characters" }) + id!: string; + + @IsString({ message: "name must be a string" }) + @MinLength(1, { message: "name must not be empty" }) + @MaxLength(255, { message: "name must not exceed 255 characters" }) + name!: string; + + @IsOptional() + @IsString({ message: "description must be a string" }) + description?: string | null; + + @IsOptional() + @IsString({ message: "domain must be a string" }) + domain?: string | null; + + @IsOptional() + @IsString({ message: "status must be a string" }) + status?: string | null; + + // jarvis-brain project priority can be a number, string, or null + @IsOptional() + priority?: number | string | null; + + @IsOptional() + @IsNumber({}, { message: "progress must be a number" }) + progress?: number | null; + + @IsOptional() + @IsString({ message: "repo must be a string" }) + repo?: string | null; + + @IsOptional() + @IsString({ message: "branch must be a string" }) + branch?: string | null; + + @IsOptional() + @IsString({ message: "current_milestone must be a string" }) + current_milestone?: string | null; + + @IsOptional() + @IsString({ message: "next_milestone must be a string" }) + next_milestone?: string | null; + + @IsOptional() + @IsString({ message: "blocker must be a string" }) + blocker?: string | null; + + @IsOptional() + @IsString({ message: "owner must be a string" }) + owner?: string | null; + + @IsOptional() + @IsString({ message: "docs_path must be a string" }) + docs_path?: string | null; + + @IsOptional() + @IsString({ message: "created must be a string" }) + created?: string | null; + + @IsOptional() + @IsString({ message: "updated must be a string" }) + updated?: string | null; + + @IsOptional() + @IsString({ message: "target_date must be a string" }) + target_date?: string | null; + + @IsOptional() + @IsString({ message: "notes must be a string" }) + notes?: string | null; + + @IsOptional() + @IsString({ message: "notes_nontechnical must be a string" }) + notes_nontechnical?: string | null; + + @IsOptional() + @IsString({ message: "parent must be a string" }) + parent?: string | null; +} diff --git a/apps/api/src/import/dto/import-response.dto.ts b/apps/api/src/import/dto/import-response.dto.ts new file mode 100644 index 0000000..ca713a3 --- /dev/null +++ b/apps/api/src/import/dto/import-response.dto.ts @@ -0,0 +1,5 @@ +export interface ImportResponseDto { + imported: number; + skipped: number; + errors: string[]; +} diff --git a/apps/api/src/import/dto/import-task.dto.ts b/apps/api/src/import/dto/import-task.dto.ts new file mode 100644 index 0000000..7115876 --- /dev/null +++ b/apps/api/src/import/dto/import-task.dto.ts @@ -0,0 +1,76 @@ +import { IsArray, IsNumber, IsOptional, IsString, MaxLength, MinLength } from "class-validator"; + +/** + * DTO for a single jarvis-brain task record. + * This matches the task object shape consumed by scripts/migrate-brain.ts. + */ +export class ImportTaskDto { + @IsString({ message: "id must be a string" }) + @MinLength(1, { message: "id must not be empty" }) + @MaxLength(255, { message: "id must not exceed 255 characters" }) + id!: string; + + @IsString({ message: "title must be a string" }) + @MinLength(1, { message: "title must not be empty" }) + @MaxLength(255, { message: "title must not exceed 255 characters" }) + title!: string; + + @IsOptional() + @IsString({ message: "domain must be a string" }) + domain?: string | null; + + @IsOptional() + @IsString({ message: "project must be a string" }) + project?: string | null; + + @IsOptional() + @IsArray({ message: "related must be an array" }) + @IsString({ each: true, message: "related items must be strings" }) + related?: string[]; + + @IsOptional() + @IsString({ message: "priority must be a string" }) + priority?: string | null; + + @IsOptional() + @IsString({ message: "status must be a string" }) + status?: string | null; + + @IsOptional() + @IsNumber({}, { message: "progress must be a number" }) + progress?: number | null; + + @IsOptional() + @IsString({ message: "due must be a string" }) + due?: string | null; + + @IsOptional() + @IsArray({ message: "blocks must be an array" }) + @IsString({ each: true, message: "blocks items must be strings" }) + blocks?: string[]; + + @IsOptional() + @IsArray({ message: "blocked_by must be an array" }) + @IsString({ each: true, message: "blocked_by items must be strings" }) + blocked_by?: string[]; + + @IsOptional() + @IsString({ message: "assignee must be a string" }) + assignee?: string | null; + + @IsOptional() + @IsString({ message: "created must be a string" }) + created?: string | null; + + @IsOptional() + @IsString({ message: "updated must be a string" }) + updated?: string | null; + + @IsOptional() + @IsString({ message: "notes must be a string" }) + notes?: string | null; + + @IsOptional() + @IsString({ message: "notes_nontechnical must be a string" }) + notes_nontechnical?: string | null; +} diff --git a/apps/api/src/import/dto/index.ts b/apps/api/src/import/dto/index.ts new file mode 100644 index 0000000..650d624 --- /dev/null +++ b/apps/api/src/import/dto/index.ts @@ -0,0 +1,3 @@ +export { ImportTaskDto } from "./import-task.dto"; +export { ImportProjectDto } from "./import-project.dto"; +export type { ImportResponseDto } from "./import-response.dto"; diff --git a/apps/api/src/import/import.controller.ts b/apps/api/src/import/import.controller.ts new file mode 100644 index 0000000..84ff794 --- /dev/null +++ b/apps/api/src/import/import.controller.ts @@ -0,0 +1,33 @@ +import { Body, Controller, ParseArrayPipe, Post, UseGuards } from "@nestjs/common"; +import type { AuthUser } from "@mosaic/shared"; +import { CurrentUser } from "../auth/decorators/current-user.decorator"; +import { AdminGuard } from "../auth/guards/admin.guard"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { Workspace } from "../common/decorators"; +import { WorkspaceGuard } from "../common/guards"; +import { ImportProjectDto, type ImportResponseDto, ImportTaskDto } from "./dto"; +import { ImportService } from "./import.service"; + +@Controller("import") +@UseGuards(AuthGuard, WorkspaceGuard, AdminGuard) +export class ImportController { + constructor(private readonly importService: ImportService) {} + + @Post("tasks") + async importTasks( + @Body(new ParseArrayPipe({ items: ImportTaskDto })) taskPayload: ImportTaskDto[], + @Workspace() workspaceId: string, + @CurrentUser() user: AuthUser + ): Promise { + return this.importService.importTasks(workspaceId, user.id, taskPayload); + } + + @Post("projects") + async importProjects( + @Body(new ParseArrayPipe({ items: ImportProjectDto })) projectPayload: ImportProjectDto[], + @Workspace() workspaceId: string, + @CurrentUser() user: AuthUser + ): Promise { + return this.importService.importProjects(workspaceId, user.id, projectPayload); + } +} diff --git a/apps/api/src/import/import.module.ts b/apps/api/src/import/import.module.ts new file mode 100644 index 0000000..3c80b3c --- /dev/null +++ b/apps/api/src/import/import.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { AuthModule } from "../auth/auth.module"; +import { PrismaModule } from "../prisma/prisma.module"; +import { ImportController } from "./import.controller"; +import { ImportService } from "./import.service"; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [ImportController], + providers: [ImportService], + exports: [ImportService], +}) +export class ImportModule {} diff --git a/apps/api/src/import/import.service.spec.ts b/apps/api/src/import/import.service.spec.ts new file mode 100644 index 0000000..21afd24 --- /dev/null +++ b/apps/api/src/import/import.service.spec.ts @@ -0,0 +1,251 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { ProjectStatus, TaskPriority, TaskStatus } from "@prisma/client"; +import { ImportService } from "./import.service"; +import { PrismaService } from "../prisma/prisma.service"; + +describe("ImportService", () => { + let service: ImportService; + + const mockPrismaService = { + withWorkspaceContext: vi.fn(), + domain: { + findUnique: vi.fn(), + create: vi.fn(), + }, + project: { + findFirst: vi.fn(), + create: vi.fn(), + }, + task: { + findFirst: vi.fn(), + create: vi.fn(), + }, + }; + + const workspaceId = "550e8400-e29b-41d4-a716-446655440001"; + const userId = "550e8400-e29b-41d4-a716-446655440002"; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ImportService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + ], + }).compile(); + + service = module.get(ImportService); + vi.clearAllMocks(); + + mockPrismaService.withWorkspaceContext.mockImplementation( + async (_userId: string, _workspaceId: string, fn: (client: unknown) => Promise) => { + return fn(mockPrismaService); + } + ); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("importTasks", () => { + it("maps status/priority/domain and imports a task", async () => { + mockPrismaService.task.findFirst.mockResolvedValue(null); + mockPrismaService.domain.findUnique.mockResolvedValue(null); + mockPrismaService.domain.create.mockResolvedValue({ id: "domain-id" }); + mockPrismaService.project.findFirst.mockResolvedValue(null); + mockPrismaService.task.create.mockResolvedValue({ id: "task-id" }); + + const result = await service.importTasks(workspaceId, userId, [ + { + id: "task-1", + title: "Import me", + domain: "Platform Ops", + status: "in-progress", + priority: "critical", + project: null, + related: [], + blocks: [], + blocked_by: [], + progress: 42, + due: "2026-03-15", + created: "2026-03-01T10:00:00.000Z", + updated: "2026-03-05T12:00:00.000Z", + assignee: null, + notes: "notes", + notes_nontechnical: "non technical", + }, + ]); + + expect(result).toEqual({ imported: 1, skipped: 0, errors: [] }); + expect(mockPrismaService.task.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + title: "Import me", + status: TaskStatus.IN_PROGRESS, + priority: TaskPriority.HIGH, + domainId: "domain-id", + }), + }) + ); + }); + + it("skips existing task by brainId", async () => { + mockPrismaService.task.findFirst.mockResolvedValue({ id: "existing-task-id" }); + + const result = await service.importTasks(workspaceId, userId, [ + { + id: "task-1", + title: "Existing", + domain: null, + status: "pending", + priority: "medium", + project: null, + related: [], + blocks: [], + blocked_by: [], + progress: null, + due: null, + created: null, + updated: null, + assignee: null, + notes: null, + notes_nontechnical: null, + }, + ]); + + expect(result.imported).toBe(0); + expect(result.skipped).toBe(1); + expect(mockPrismaService.task.create).not.toHaveBeenCalled(); + }); + + it("collects mapping/missing-project errors while importing", async () => { + mockPrismaService.task.findFirst.mockResolvedValue(null); + mockPrismaService.project.findFirst.mockResolvedValue(null); + mockPrismaService.task.create.mockResolvedValue({ id: "task-id" }); + + const result = await service.importTasks(workspaceId, userId, [ + { + id: "task-1", + title: "Needs project", + domain: null, + status: "mystery-status", + priority: "mystery-priority", + project: "brain-project-1", + related: [], + blocks: [], + blocked_by: [], + progress: null, + due: null, + created: null, + updated: null, + assignee: null, + notes: null, + notes_nontechnical: null, + }, + ]); + + expect(result.imported).toBe(1); + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.stringContaining('Unknown task status "mystery-status"'), + expect.stringContaining('Unknown task priority "mystery-priority"'), + expect.stringContaining('referenced project "brain-project-1" not found'), + ]) + ); + expect(mockPrismaService.task.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: TaskStatus.NOT_STARTED, + priority: TaskPriority.MEDIUM, + projectId: null, + }), + }) + ); + }); + }); + + describe("importProjects", () => { + it("maps status/domain and imports a project", async () => { + mockPrismaService.project.findFirst.mockResolvedValue(null); + mockPrismaService.domain.findUnique.mockResolvedValue(null); + mockPrismaService.domain.create.mockResolvedValue({ id: "domain-id" }); + mockPrismaService.project.create.mockResolvedValue({ id: "project-id" }); + + const result = await service.importProjects(workspaceId, userId, [ + { + id: "project-1", + name: "Project One", + description: "desc", + domain: "Backend", + status: "in-progress", + priority: "high", + progress: 50, + repo: "git@example.com/repo", + branch: "main", + current_milestone: "MS21", + next_milestone: "MS22", + blocker: null, + owner: "owner", + docs_path: "docs/PRD.md", + created: "2026-03-01", + updated: "2026-03-05", + target_date: "2026-04-01", + notes: "notes", + notes_nontechnical: "non tech", + parent: null, + }, + ]); + + expect(result).toEqual({ imported: 1, skipped: 0, errors: [] }); + expect(mockPrismaService.project.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + name: "Project One", + status: ProjectStatus.ACTIVE, + domainId: "domain-id", + }), + }) + ); + }); + + it("captures create failures as errors", async () => { + mockPrismaService.project.findFirst.mockResolvedValue(null); + mockPrismaService.project.create.mockRejectedValue(new Error("db failed")); + + const result = await service.importProjects(workspaceId, userId, [ + { + id: "project-1", + name: "Project One", + description: null, + domain: null, + status: "planning", + priority: null, + progress: null, + repo: null, + branch: null, + current_milestone: null, + next_milestone: null, + blocker: null, + owner: null, + docs_path: null, + created: null, + updated: null, + target_date: null, + notes: null, + notes_nontechnical: null, + parent: null, + }, + ]); + + expect(result.imported).toBe(0); + expect(result.skipped).toBe(1); + expect(result.errors).toEqual([ + expect.stringContaining("project project-1: failed to import: db failed"), + ]); + }); + }); +}); diff --git a/apps/api/src/import/import.service.ts b/apps/api/src/import/import.service.ts new file mode 100644 index 0000000..87a1969 --- /dev/null +++ b/apps/api/src/import/import.service.ts @@ -0,0 +1,496 @@ +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); + } +}