425 lines
16 KiB
TypeScript
425 lines
16 KiB
TypeScript
import { ApprovalState, EvidenceStrength, VerificationState, type Prisma } from "@prisma/client";
|
|
import { prisma } from "../db/prisma.js";
|
|
import type {
|
|
AcceptedTruthItemDto,
|
|
AcceptedTruthKind,
|
|
AcceptedTruthListFilters,
|
|
AcceptedTruthStatus,
|
|
CreateOperatingLogEntryRequestDto,
|
|
EvidenceStrength as EvidenceStrengthDto,
|
|
OperatingLogEntryDto,
|
|
OperatingLogListFilters,
|
|
ProjectUpdateDto,
|
|
ProjectUpdateListFilters,
|
|
ProjectUpdateTargetArea,
|
|
ReviewMutationResponseDto,
|
|
} from "../types/api.js";
|
|
|
|
type ProjectRecord = { id: string; slug: string };
|
|
type ReviewRecord = Prisma.ReviewQueueItemGetPayload<Record<string, never>>;
|
|
type TruthRecord = Prisma.AcceptedTruthItemGetPayload<Record<string, never>>;
|
|
type UpdateRecord = Prisma.ProjectUpdateGetPayload<Record<string, never>>;
|
|
type LogRecord = Prisma.OperatingLogEntryGetPayload<Record<string, never>>;
|
|
|
|
const findProjectRecord = (projectIdOrSlug: string): Promise<ProjectRecord | null> =>
|
|
prisma.project.findFirst({
|
|
where: {
|
|
deletedAt: null,
|
|
OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }],
|
|
},
|
|
select: { id: true, slug: true },
|
|
});
|
|
|
|
const findReviewRecord = async (
|
|
projectId: string,
|
|
reviewItemId: string,
|
|
): Promise<ReviewRecord | null> =>
|
|
prisma.reviewQueueItem.findFirst({
|
|
where: {
|
|
projectId,
|
|
deletedAt: null,
|
|
OR: [{ id: reviewItemId }, { slug: reviewItemId }, { readableId: reviewItemId }],
|
|
},
|
|
});
|
|
|
|
const toEvidenceStrengthEnum = (value: EvidenceStrengthDto | string | null | undefined): EvidenceStrength => {
|
|
if (value === "verified") return EvidenceStrength.verified;
|
|
if (value === "strong") return EvidenceStrength.strong;
|
|
if (value === "medium") return EvidenceStrength.medium;
|
|
return EvidenceStrength.weak;
|
|
};
|
|
|
|
const toEvidenceStrengthDto = (value: EvidenceStrength): EvidenceStrengthDto => value;
|
|
|
|
const normalizeKind = (type: string): AcceptedTruthKind => {
|
|
if (type === "memory_rule") return "memory_rule";
|
|
if (type === "documentation_update") return "documentation";
|
|
if (type === "open_question") return "open_question";
|
|
if (type === "feature") return "feature";
|
|
if (type === "bug") return "bug";
|
|
if (type === "task") return "task";
|
|
if (type === "decision") return "decision";
|
|
if (type === "knowledge") return "knowledge";
|
|
if (type === "risk") return "risk";
|
|
if (type === "assumption") return "assumption";
|
|
return "knowledge";
|
|
};
|
|
|
|
const targetAreaForKind = (kind: AcceptedTruthKind): ProjectUpdateTargetArea => {
|
|
if (kind === "feature") return "feature-map";
|
|
if (kind === "bug") return "bugs";
|
|
if (kind === "task") return "tasks";
|
|
if (kind === "decision") return "decisions";
|
|
if (kind === "memory_rule") return "memory";
|
|
if (kind === "documentation") return "documentation";
|
|
if (kind === "risk") return "risks";
|
|
if (kind === "open_question") return "questions";
|
|
return "knowledge";
|
|
};
|
|
|
|
const toTruthDto = (record: TruthRecord, projectSlug: string): AcceptedTruthItemDto => ({
|
|
id: record.slug ?? record.readableId ?? record.id,
|
|
projectId: projectSlug,
|
|
sourceReviewItemId: record.sourceReviewItemId,
|
|
kind: record.kind as AcceptedTruthKind,
|
|
title: record.title,
|
|
summary: record.summary,
|
|
status: record.status as AcceptedTruthStatus,
|
|
source: record.source,
|
|
confidence: record.confidence,
|
|
evidenceStrength: toEvidenceStrengthDto(record.evidenceStrength),
|
|
createdAt: record.createdAt.toISOString(),
|
|
updatedAt: record.updatedAt.toISOString(),
|
|
});
|
|
|
|
const toProjectUpdateDto = (record: UpdateRecord, projectSlug: string): ProjectUpdateDto => ({
|
|
id: record.slug ?? record.readableId ?? record.id,
|
|
projectId: projectSlug,
|
|
sourceTruthItemId: record.sourceTruthItemId,
|
|
kind: record.kind as AcceptedTruthKind,
|
|
title: record.title,
|
|
summary: record.summary,
|
|
targetArea: record.targetArea as ProjectUpdateTargetArea,
|
|
status: record.status as ProjectUpdateDto["status"],
|
|
confidence: record.confidence,
|
|
evidenceStrength: toEvidenceStrengthDto(record.evidenceStrength),
|
|
createdAt: record.createdAt.toISOString(),
|
|
updatedAt: record.updatedAt.toISOString(),
|
|
...(record.appliedAt ? { appliedAt: record.appliedAt.toISOString() } : {}),
|
|
});
|
|
|
|
const linkedIdsFromJson = (value: Prisma.JsonValue): string[] =>
|
|
Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
|
|
const toLogDto = (record: LogRecord, projectSlug: string): OperatingLogEntryDto => ({
|
|
id: record.slug ?? record.readableId ?? record.id,
|
|
projectId: projectSlug,
|
|
timestamp: record.timestamp.toISOString(),
|
|
type: record.type as OperatingLogEntryDto["type"],
|
|
title: record.title,
|
|
summary: record.summary,
|
|
linkedEntityIds: linkedIdsFromJson(record.linkedEntityIdsJson),
|
|
source: record.source,
|
|
importance: record.importance as OperatingLogEntryDto["importance"],
|
|
createdAt: record.createdAt.toISOString(),
|
|
updatedAt: record.updatedAt.toISOString(),
|
|
});
|
|
|
|
const paginateMeta = <T>(
|
|
items: T[],
|
|
total: number,
|
|
page: number,
|
|
pageSize: number,
|
|
sort?: string,
|
|
filters?: Record<string, unknown>,
|
|
) => ({
|
|
items,
|
|
total,
|
|
page,
|
|
pageSize,
|
|
hasNextPage: page < Math.ceil(total / pageSize),
|
|
hasPreviousPage: page > 1,
|
|
...(sort ? { sort } : {}),
|
|
...(filters ? { filters: filters as Record<string, string | number | boolean | string[]> } : {}),
|
|
});
|
|
|
|
export const listAcceptedTruthFromDatabase = async (
|
|
projectIdOrSlug: string,
|
|
filters: AcceptedTruthListFilters,
|
|
options: { page: number; pageSize: number; sort?: string },
|
|
) => {
|
|
const project = await findProjectRecord(projectIdOrSlug);
|
|
if (!project) return undefined;
|
|
const where: Prisma.AcceptedTruthItemWhereInput = {
|
|
projectId: project.id,
|
|
deletedAt: null,
|
|
...(filters.kind?.length ? { kind: { in: filters.kind } } : {}),
|
|
...(filters.status?.length ? { status: { in: filters.status } } : {}),
|
|
...(filters.confidence !== undefined ? { confidence: filters.confidence } : {}),
|
|
...(filters.evidenceStrength?.length
|
|
? { evidenceStrength: { in: filters.evidenceStrength.map(toEvidenceStrengthEnum) } }
|
|
: {}),
|
|
...(filters.sourceReviewItemId ? { sourceReviewItemId: filters.sourceReviewItemId } : {}),
|
|
};
|
|
const total = await prisma.acceptedTruthItem.count({ where });
|
|
const records = await prisma.acceptedTruthItem.findMany({
|
|
where,
|
|
orderBy: { createdAt: options.sort?.endsWith(":asc") ? "asc" : "desc" },
|
|
skip: (options.page - 1) * options.pageSize,
|
|
take: options.pageSize,
|
|
});
|
|
return paginateMeta(records.map((record) => toTruthDto(record, project.slug)), total, options.page, options.pageSize, options.sort, filters);
|
|
};
|
|
|
|
export const materializeReviewItemAsTruthInDatabase = async (
|
|
projectIdOrSlug: string,
|
|
reviewItemId: string,
|
|
): Promise<ReviewMutationResponseDto | undefined> => {
|
|
const project = await findProjectRecord(projectIdOrSlug);
|
|
if (!project) return undefined;
|
|
const review = await findReviewRecord(project.id, reviewItemId);
|
|
if (!review) return undefined;
|
|
if (!["accepted", "converted", "merged"].includes(review.status)) {
|
|
return {
|
|
success: true,
|
|
message: `Review item '${reviewItemId}' is not accepted, converted or merged yet.`,
|
|
updatedEntityId: reviewItemId,
|
|
affectedEntities: [reviewItemId],
|
|
};
|
|
}
|
|
const existing = await prisma.acceptedTruthItem.findFirst({
|
|
where: { projectId: project.id, sourceReviewItemId: review.id, deletedAt: null },
|
|
});
|
|
if (existing) {
|
|
return {
|
|
success: true,
|
|
message: `Accepted truth already exists for review item '${reviewItemId}'.`,
|
|
updatedEntityId: reviewItemId,
|
|
createdEntityId: existing.slug ?? existing.readableId ?? existing.id,
|
|
affectedEntities: [reviewItemId, existing.slug ?? existing.id],
|
|
};
|
|
}
|
|
const createdAt = new Date();
|
|
const kind = normalizeKind(review.createdEntityType ?? review.type);
|
|
const slug = `truth-${project.slug}-${createdAt.getTime()}`;
|
|
const truth = await prisma.acceptedTruthItem.create({
|
|
data: {
|
|
projectId: project.id,
|
|
slug,
|
|
readableId: slug,
|
|
sourceReviewItemId: review.id,
|
|
kind,
|
|
title: review.title,
|
|
summary: review.description,
|
|
status: review.status === "converted" ? "converted" : review.status === "merged" ? "merged" : "accepted",
|
|
source: review.source,
|
|
confidence: review.confidence,
|
|
approvalState: ApprovalState.approved,
|
|
evidenceStrength: review.evidenceStrength,
|
|
verificationState: VerificationState.verified,
|
|
payloadJson: { reviewItemId: review.id, reviewSlug: review.slug, suggestedAction: review.suggestedAction },
|
|
provenanceJson: { createdFrom: "P6 review materialization" },
|
|
verifiedAt: createdAt,
|
|
verifiedBy: "api",
|
|
},
|
|
});
|
|
await createOperatingLogEntryInDatabase(project.slug, {
|
|
type: "accepted truth",
|
|
title: `Accepted truth created: ${review.title}`,
|
|
summary: review.description,
|
|
linkedEntityIds: [review.id, truth.id],
|
|
source: "review_queue",
|
|
importance: review.priority === "critical" ? "critical" : review.priority === "important" ? "important" : "normal",
|
|
});
|
|
return {
|
|
success: true,
|
|
message: `Review item '${reviewItemId}' materialized as accepted truth.`,
|
|
updatedEntityId: reviewItemId,
|
|
createdEntityId: truth.slug ?? truth.id,
|
|
affectedEntities: [reviewItemId, truth.slug ?? truth.id],
|
|
};
|
|
};
|
|
|
|
export const listProjectUpdatesFromDatabase = async (
|
|
projectIdOrSlug: string,
|
|
filters: ProjectUpdateListFilters,
|
|
options: { page: number; pageSize: number; sort?: string },
|
|
) => {
|
|
const project = await findProjectRecord(projectIdOrSlug);
|
|
if (!project) return undefined;
|
|
const where: Prisma.ProjectUpdateWhereInput = {
|
|
projectId: project.id,
|
|
deletedAt: null,
|
|
...(filters.status?.length ? { status: { in: filters.status } } : {}),
|
|
...(filters.targetArea?.length ? { targetArea: { in: filters.targetArea } } : {}),
|
|
...(filters.kind?.length ? { kind: { in: filters.kind } } : {}),
|
|
...(filters.sourceTruthItemId ? { sourceTruthItemId: filters.sourceTruthItemId } : {}),
|
|
};
|
|
const total = await prisma.projectUpdate.count({ where });
|
|
const records = await prisma.projectUpdate.findMany({
|
|
where,
|
|
orderBy: { createdAt: options.sort?.endsWith(":asc") ? "asc" : "desc" },
|
|
skip: (options.page - 1) * options.pageSize,
|
|
take: options.pageSize,
|
|
});
|
|
return paginateMeta(records.map((record) => toProjectUpdateDto(record, project.slug)), total, options.page, options.pageSize, options.sort, filters);
|
|
};
|
|
|
|
export const createProjectUpdateFromTruthInDatabase = async (
|
|
projectIdOrSlug: string,
|
|
truthItemId: string,
|
|
): Promise<ReviewMutationResponseDto | undefined> => {
|
|
const project = await findProjectRecord(projectIdOrSlug);
|
|
if (!project) return undefined;
|
|
const truth = await prisma.acceptedTruthItem.findFirst({
|
|
where: {
|
|
projectId: project.id,
|
|
deletedAt: null,
|
|
OR: [{ id: truthItemId }, { slug: truthItemId }, { readableId: truthItemId }],
|
|
},
|
|
});
|
|
if (!truth) return undefined;
|
|
const existing = await prisma.projectUpdate.findFirst({
|
|
where: { projectId: project.id, sourceTruthItemId: truth.id, deletedAt: null },
|
|
});
|
|
if (existing) {
|
|
return {
|
|
success: true,
|
|
message: `Project update already exists for accepted truth '${truthItemId}'.`,
|
|
updatedEntityId: truthItemId,
|
|
createdEntityId: existing.slug ?? existing.id,
|
|
affectedEntities: [truthItemId, existing.slug ?? existing.id],
|
|
};
|
|
}
|
|
const createdAt = new Date();
|
|
const slug = `update-${project.slug}-${createdAt.getTime()}`;
|
|
const update = await prisma.projectUpdate.create({
|
|
data: {
|
|
projectId: project.id,
|
|
slug,
|
|
readableId: slug,
|
|
sourceTruthItemId: truth.id,
|
|
kind: truth.kind,
|
|
title: truth.title,
|
|
summary: truth.summary,
|
|
targetArea: targetAreaForKind(truth.kind as AcceptedTruthKind),
|
|
status: "ready",
|
|
confidence: truth.confidence,
|
|
approvalState: ApprovalState.pending,
|
|
evidenceStrength: truth.evidenceStrength,
|
|
payloadJson: { acceptedTruthItemId: truth.id },
|
|
provenanceJson: { createdFrom: "P6 accepted truth" },
|
|
},
|
|
});
|
|
await createOperatingLogEntryInDatabase(project.slug, {
|
|
type: "project update",
|
|
title: `Project update prepared: ${truth.title}`,
|
|
summary: truth.summary,
|
|
linkedEntityIds: [truth.id, update.id],
|
|
source: "accepted_truth",
|
|
importance: truth.confidence >= 90 ? "important" : "normal",
|
|
});
|
|
return {
|
|
success: true,
|
|
message: `Project update prepared from accepted truth '${truthItemId}'.`,
|
|
updatedEntityId: truthItemId,
|
|
createdEntityId: update.slug ?? update.id,
|
|
affectedEntities: [truthItemId, update.slug ?? update.id],
|
|
};
|
|
};
|
|
|
|
export const applyProjectUpdateInDatabase = async (
|
|
projectIdOrSlug: string,
|
|
projectUpdateId: string,
|
|
): Promise<ReviewMutationResponseDto | undefined | "PROJECT_UPDATE_ALREADY_APPLIED"> => {
|
|
const project = await findProjectRecord(projectIdOrSlug);
|
|
if (!project) return undefined;
|
|
const existing = await prisma.projectUpdate.findFirst({
|
|
where: {
|
|
projectId: project.id,
|
|
deletedAt: null,
|
|
OR: [{ id: projectUpdateId }, { slug: projectUpdateId }, { readableId: projectUpdateId }],
|
|
},
|
|
});
|
|
if (!existing) return undefined;
|
|
const appliedAt = new Date();
|
|
const claimed = await prisma.projectUpdate.updateMany({
|
|
where: { id: existing.id, status: "ready" },
|
|
data: {
|
|
status: "applied",
|
|
approvalState: ApprovalState.approved,
|
|
appliedAt,
|
|
updatedBy: "api",
|
|
},
|
|
});
|
|
if (claimed.count !== 1) return "PROJECT_UPDATE_ALREADY_APPLIED";
|
|
const updated = await prisma.projectUpdate.findUniqueOrThrow({ where: { id: existing.id } });
|
|
await createOperatingLogEntryInDatabase(project.slug, {
|
|
type: "project update",
|
|
title: `Project update applied: ${updated.title}`,
|
|
summary: updated.summary,
|
|
linkedEntityIds: [updated.id, updated.sourceTruthItemId],
|
|
source: "project_update",
|
|
importance: "important",
|
|
});
|
|
return {
|
|
success: true,
|
|
message: `Project update '${projectUpdateId}' applied.`,
|
|
updatedEntityId: updated.slug ?? updated.id,
|
|
affectedEntities: [updated.slug ?? updated.id, updated.sourceTruthItemId],
|
|
};
|
|
};
|
|
|
|
export const listOperatingLogEntriesFromDatabase = async (
|
|
projectIdOrSlug: string,
|
|
filters: OperatingLogListFilters,
|
|
options: { page: number; pageSize: number; sort?: string },
|
|
) => {
|
|
const project = await findProjectRecord(projectIdOrSlug);
|
|
if (!project) return undefined;
|
|
const where: Prisma.OperatingLogEntryWhereInput = {
|
|
projectId: project.id,
|
|
deletedAt: null,
|
|
...(filters.type?.length ? { type: { in: filters.type } } : {}),
|
|
...(filters.source?.length ? { source: { in: filters.source } } : {}),
|
|
...(filters.importance?.length ? { importance: { in: filters.importance } } : {}),
|
|
};
|
|
const total = await prisma.operatingLogEntry.count({ where });
|
|
const records = await prisma.operatingLogEntry.findMany({
|
|
where,
|
|
orderBy: { timestamp: options.sort?.endsWith(":asc") ? "asc" : "desc" },
|
|
skip: (options.page - 1) * options.pageSize,
|
|
take: options.pageSize,
|
|
});
|
|
const items = records
|
|
.map((record) => toLogDto(record, project.slug))
|
|
.filter((entry) => !filters.linkedEntityId || entry.linkedEntityIds.includes(filters.linkedEntityId));
|
|
return paginateMeta(items, total, options.page, options.pageSize, options.sort, filters);
|
|
};
|
|
|
|
export const createOperatingLogEntryInDatabase = async (
|
|
projectIdOrSlug: string,
|
|
body: CreateOperatingLogEntryRequestDto,
|
|
): Promise<OperatingLogEntryDto | undefined> => {
|
|
const project = await findProjectRecord(projectIdOrSlug);
|
|
if (!project) return undefined;
|
|
const createdAt = new Date();
|
|
const slug = `log-${project.slug}-${createdAt.getTime()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
const record = await prisma.operatingLogEntry.create({
|
|
data: {
|
|
projectId: project.id,
|
|
slug,
|
|
readableId: slug,
|
|
timestamp: createdAt,
|
|
type: body.type,
|
|
title: body.title,
|
|
summary: body.summary,
|
|
linkedEntityIdsJson: body.linkedEntityIds ?? [],
|
|
source: body.source ?? "manual",
|
|
importance: body.importance ?? "normal",
|
|
payloadJson: { createdFrom: "P6 operating log API" },
|
|
},
|
|
});
|
|
return toLogDto(record, project.slug);
|
|
};
|