All checks were successful
ci/woodpecker/push/api Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
497 lines
14 KiB
TypeScript
497 lines
14 KiB
TypeScript
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);
|
|
}
|
|
}
|