feat(api): add admin bulk import endpoints (MS21-MIG-004) #567
@@ -45,6 +45,7 @@ import { PersonalitiesModule } from "./personalities/personalities.module";
|
|||||||
import { WorkspacesModule } from "./workspaces/workspaces.module";
|
import { WorkspacesModule } from "./workspaces/workspaces.module";
|
||||||
import { AdminModule } from "./admin/admin.module";
|
import { AdminModule } from "./admin/admin.module";
|
||||||
import { TeamsModule } from "./teams/teams.module";
|
import { TeamsModule } from "./teams/teams.module";
|
||||||
|
import { ImportModule } from "./import/import.module";
|
||||||
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -113,6 +114,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
|
|||||||
WorkspacesModule,
|
WorkspacesModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
TeamsModule,
|
TeamsModule,
|
||||||
|
ImportModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController, CsrfController],
|
controllers: [AppController, CsrfController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
89
apps/api/src/import/dto/import-project.dto.ts
Normal file
89
apps/api/src/import/dto/import-project.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
5
apps/api/src/import/dto/import-response.dto.ts
Normal file
5
apps/api/src/import/dto/import-response.dto.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface ImportResponseDto {
|
||||||
|
imported: number;
|
||||||
|
skipped: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
76
apps/api/src/import/dto/import-task.dto.ts
Normal file
76
apps/api/src/import/dto/import-task.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
3
apps/api/src/import/dto/index.ts
Normal file
3
apps/api/src/import/dto/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { ImportTaskDto } from "./import-task.dto";
|
||||||
|
export { ImportProjectDto } from "./import-project.dto";
|
||||||
|
export type { ImportResponseDto } from "./import-response.dto";
|
||||||
33
apps/api/src/import/import.controller.ts
Normal file
33
apps/api/src/import/import.controller.ts
Normal file
@@ -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<ImportResponseDto> {
|
||||||
|
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<ImportResponseDto> {
|
||||||
|
return this.importService.importProjects(workspaceId, user.id, projectPayload);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/api/src/import/import.module.ts
Normal file
13
apps/api/src/import/import.module.ts
Normal file
@@ -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 {}
|
||||||
251
apps/api/src/import/import.service.spec.ts
Normal file
251
apps/api/src/import/import.service.spec.ts
Normal file
@@ -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>(ImportService);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
mockPrismaService.withWorkspaceContext.mockImplementation(
|
||||||
|
async (_userId: string, _workspaceId: string, fn: (client: unknown) => Promise<unknown>) => {
|
||||||
|
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"),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
496
apps/api/src/import/import.service.ts
Normal file
496
apps/api/src/import/import.service.ts
Normal file
@@ -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<ImportResponseDto> {
|
||||||
|
const errors: string[] = [];
|
||||||
|
let imported = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
const importTimestamp = new Date().toISOString();
|
||||||
|
const seenBrainTaskIds = new Set<string>();
|
||||||
|
const domainIdBySlug = new Map<string, string>();
|
||||||
|
const projectIdByBrainId = new Map<string, string | null>();
|
||||||
|
|
||||||
|
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<ImportResponseDto> {
|
||||||
|
const errors: string[] = [];
|
||||||
|
let imported = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
const importTimestamp = new Date().toISOString();
|
||||||
|
const seenBrainProjectIds = new Set<string>();
|
||||||
|
const domainIdBySlug = new Map<string, string>();
|
||||||
|
|
||||||
|
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<string, string | null>,
|
||||||
|
taskBrainId: string,
|
||||||
|
errors: string[]
|
||||||
|
): Promise<string | null> {
|
||||||
|
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<string, string>
|
||||||
|
): Promise<string | null> {
|
||||||
|
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<string, unknown>): Prisma.InputJsonValue {
|
||||||
|
return value as Prisma.InputJsonValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user