evOo-local/packages/db/src/project-goals.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

200 lines
5.6 KiB
TypeScript

import type { Actor, ProjectGoal, ProjectGoalStatus } from "@rakazo/contracts";
import type { PrismaClient } from "./client.js";
import { IsolationError } from "./scope.js";
export const PROJECT_GOAL_STATUSES = ["open", "doing", "done"] as const;
export const VOICE_FIRST_GOAL_TITLES = ["Voice First", "Voice First Feature"] as const;
const EVOO_SECTION_NAME = /^ev[oO]{2}s?$/;
type ProjectGoalRow = {
id: string;
sectionId: string;
groupId: string | null;
title: string;
description: string | null;
status: string;
createdAt: Date;
updatedAt: Date;
};
export function isProjectGoalStatus(value: unknown): value is ProjectGoalStatus {
return typeof value === "string" && (PROJECT_GOAL_STATUSES as readonly string[]).includes(value);
}
export function coerceProjectGoalStatus(value: unknown): ProjectGoalStatus {
return isProjectGoalStatus(value) ? value : "open";
}
export function mapProjectGoal(row: ProjectGoalRow): ProjectGoal {
return {
id: row.id,
sectionId: row.sectionId,
groupId: row.groupId,
title: row.title,
description: row.description,
status: coerceProjectGoalStatus(row.status),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
async function getOwnedSection(
prisma: PrismaClient,
actor: Actor,
sectionId: string,
): Promise<{ id: string; name: string }> {
const section = await prisma.botSection.findFirst({
where: {
id: sectionId,
spaceId: actor.spaceId,
userId: actor.userId,
},
select: { id: true, name: true },
});
if (!section) throw new IsolationError();
return section;
}
async function assertOwnedGroupInSection(
prisma: PrismaClient,
actor: Actor,
groupId: string,
sectionId: string,
) {
const group = await prisma.chatGroup.findFirst({
where: {
id: groupId,
spaceId: actor.spaceId,
userId: actor.userId,
sectionId,
archivedAt: null,
},
select: { id: true },
});
if (!group) throw new IsolationError();
}
async function ensureVoiceFirstGoal(
prisma: PrismaClient,
actor: Actor,
section: { id: string; name: string },
linkGroupId?: string,
) {
if (!EVOO_SECTION_NAME.test(section.name.trim())) return;
const existing = await prisma.projectGoal.findFirst({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
sectionId: section.id,
OR: [
{ title: { in: [...VOICE_FIRST_GOAL_TITLES] } },
{ title: { contains: "voice first", mode: "insensitive" } },
],
},
select: { id: true },
});
if (existing) return;
if (linkGroupId) {
await assertOwnedGroupInSection(prisma, actor, linkGroupId, section.id);
}
await prisma.projectGoal.create({
data: {
spaceId: actor.spaceId,
userId: actor.userId,
sectionId: section.id,
groupId: linkGroupId ?? null,
title: "Voice First",
description:
"Voice-first Interaktion als primäre evOo-Erfahrung — sprechen statt tippen, wo es passt.",
status: "open",
},
});
}
export function createProjectGoalRepos(prisma: PrismaClient) {
return {
async listGoals(
actor: Actor,
input: { sectionId: string; groupId?: string; seedDefaults?: boolean },
): Promise<ProjectGoal[]> {
const section = await getOwnedSection(prisma, actor, input.sectionId);
if (input.seedDefaults) {
await ensureVoiceFirstGoal(prisma, actor, section, input.groupId);
}
const rows = await prisma.projectGoal.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
sectionId: input.sectionId,
},
orderBy: [{ updatedAt: "desc" }, { createdAt: "desc" }],
});
return rows.map(mapProjectGoal);
},
async createGoal(
actor: Actor,
input: {
sectionId: string;
title: string;
description?: string;
status: ProjectGoalStatus;
groupId?: string;
},
): Promise<ProjectGoal> {
await getOwnedSection(prisma, actor, input.sectionId);
if (input.groupId) {
await assertOwnedGroupInSection(prisma, actor, input.groupId, input.sectionId);
}
const row = await prisma.projectGoal.create({
data: {
spaceId: actor.spaceId,
userId: actor.userId,
sectionId: input.sectionId,
groupId: input.groupId ?? null,
title: input.title.trim(),
description: input.description?.trim() || null,
status: input.status,
},
});
return mapProjectGoal(row);
},
async updateGoal(
actor: Actor,
input: {
goalId: string;
title?: string;
description?: string | null;
status?: ProjectGoalStatus;
groupId?: string | null;
},
): Promise<ProjectGoal> {
const existing = await prisma.projectGoal.findFirst({
where: {
id: input.goalId,
spaceId: actor.spaceId,
userId: actor.userId,
},
});
if (!existing) throw new IsolationError();
if (input.groupId) {
await assertOwnedGroupInSection(prisma, actor, input.groupId, existing.sectionId);
}
const row = await prisma.projectGoal.update({
where: { id: existing.id },
data: {
...(input.title !== undefined ? { title: input.title.trim() } : {}),
...(input.description !== undefined
? { description: input.description?.trim() || null }
: {}),
...(input.status !== undefined ? { status: input.status } : {}),
...(input.groupId !== undefined ? { groupId: input.groupId } : {}),
},
});
return mapProjectGoal(row);
},
};
}