Files
projectbrain-Public/backend/src/repositories/runtimeRepository.ts
T

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);
};