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.
200 lines
5.6 KiB
TypeScript
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);
|
|
},
|
|
};
|
|
}
|