2026-06-09 20:12:09 -04:00

125 lines
3.4 KiB
TypeScript

import type {
CreateProjectInput,
DeleteProjectInput,
UpdateProjectInput,
} from "@/types/schemas/projects";
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
import { AppError } from "@/server/lib/errors";
function mapProject(project: {
id: string;
name: string;
domain: string | null;
createdAt: string;
}) {
return {
id: project.id,
name: project.name,
domain: project.domain,
createdAt: project.createdAt,
};
}
// The projects table's only unique index guards the auto-created ("Default",
// null) singleton. A UNIQUE violation while writing exactly that name/domain
// therefore means one already exists — gating on the input (not just the error
// string) keeps this from misclassifying any unrelated failure.
function isReservedDefaultConflict(
error: unknown,
input: { name: string; domain?: string },
) {
return (
input.name === "Default" &&
!input.domain &&
error instanceof Error &&
error.message.includes("UNIQUE constraint failed")
);
}
const RESERVED_DEFAULT_MESSAGE =
'A project named "Default" with no domain already exists. Pick a different name or add a domain.';
export async function listProjects(organizationId: string) {
const rows = await ProjectRepository.listProjects(organizationId);
return rows.map(mapProject);
}
// Source of truth for "which projects does this org have", guaranteeing at least
// one. Count-based — never matches on the "Default" name — so renaming the last
// project does not cause a spurious second Default to be created on next visit.
export async function listProjectsEnsuringOne(organizationId: string) {
const existing = await listProjects(organizationId);
if (existing.length > 0) {
return existing;
}
await ProjectRepository.tryCreateDefaultProject(organizationId);
return listProjects(organizationId);
}
export async function createProject(
organizationId: string,
input: CreateProjectInput,
) {
try {
const row = await ProjectRepository.createProject(
organizationId,
input.name,
input.domain,
);
return mapProject(row);
} catch (error) {
if (isReservedDefaultConflict(error, input)) {
throw new AppError("CONFLICT", RESERVED_DEFAULT_MESSAGE);
}
throw error;
}
}
export async function updateProject(
organizationId: string,
input: UpdateProjectInput,
) {
try {
const row = await ProjectRepository.updateProject(
input.projectId,
organizationId,
{ name: input.name, domain: input.domain },
);
return mapProject(row);
} catch (error) {
if (isReservedDefaultConflict(error, input)) {
throw new AppError("CONFLICT", RESERVED_DEFAULT_MESSAGE);
}
throw error;
}
}
export async function deleteProject(
organizationId: string,
input: DeleteProjectInput,
) {
const remaining = await ProjectRepository.countProjects(organizationId);
if (remaining <= 1) {
throw new AppError("CONFLICT", "You can't delete your only project.");
}
await ProjectRepository.deleteProject(input.projectId, organizationId);
return { success: true };
}
export async function getProjectForOrganization(
organizationId: string,
projectId: string,
) {
const project = await ProjectRepository.getProjectForOrganization(
projectId,
organizationId,
);
if (!project) {
throw new AppError("NOT_FOUND");
}
return mapProject(project);
}