feat(domain): complete phase 2 backend core
This commit is contained in:
@@ -0,0 +1,405 @@
|
||||
import { and, count, eq, inArray, ne } from "drizzle-orm";
|
||||
import {
|
||||
chatSessions,
|
||||
journalEntries,
|
||||
projectRevisions,
|
||||
projects,
|
||||
} from "../db/schema/domain";
|
||||
import { requireClientScope, requireOwnerScope, type DomainActor, type OwnerScope } from "../domain/actor";
|
||||
import type { DomainDatabase } from "../domain/database";
|
||||
import { conflict, DomainError, notFound } from "../domain/errors";
|
||||
import { generateId, type IdGenerator } from "../domain/id";
|
||||
import {
|
||||
calendarEventCreateSchema,
|
||||
chatMessageCreateSchema,
|
||||
chatSessionCreateSchema,
|
||||
clientActivityCreateSchema,
|
||||
clientCreateSchema,
|
||||
clientUpdateSchema,
|
||||
contractCreateSchema,
|
||||
financeTransactionCreateSchema,
|
||||
financeTransactionUpdateSchema,
|
||||
invoiceCreateSchema,
|
||||
journalEntrySchema,
|
||||
parseDomainInput,
|
||||
planningSectionCreateSchema,
|
||||
planningSectionUpdateSchema,
|
||||
projectCreateSchema,
|
||||
projectUpdateSchema,
|
||||
proposalCreateSchema,
|
||||
revisionCreateSchema,
|
||||
revisionStatusSchema,
|
||||
subscriptionCreateSchema,
|
||||
taskCreateSchema,
|
||||
taskUpdateSchema,
|
||||
calendarEventUpdateSchema,
|
||||
} from "../domain/validation";
|
||||
import { createDomainRepositories, type DomainRepositories } from "../repositories/domain";
|
||||
|
||||
export class DomainService {
|
||||
readonly repositories: DomainRepositories;
|
||||
|
||||
constructor(
|
||||
private readonly db: DomainDatabase,
|
||||
private readonly id: IdGenerator = generateId,
|
||||
) {
|
||||
this.repositories = createDomainRepositories(db);
|
||||
}
|
||||
|
||||
listClients(actor: DomainActor) {
|
||||
return this.repositories.clients.list(requireOwnerScope(actor));
|
||||
}
|
||||
|
||||
getClient(actor: DomainActor, id: string) {
|
||||
if (actor.role === "client") {
|
||||
const scope = requireClientScope(actor);
|
||||
if (scope.clientId !== id) throw notFound("Müşteri");
|
||||
return this.repositories.clients.getByPortalScope(scope) ?? this.throwNotFound("Müşteri");
|
||||
}
|
||||
return this.repositories.clients.get(requireOwnerScope(actor), id) ?? this.throwNotFound("Müşteri");
|
||||
}
|
||||
|
||||
createClient(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(clientCreateSchema, input);
|
||||
return this.repositories.clients.create(scope, { ...value, id: value.id ?? this.id() });
|
||||
}
|
||||
|
||||
updateClient(actor: DomainActor, id: string, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(clientUpdateSchema, input);
|
||||
return this.repositories.clients.update(scope, id, value) ?? this.throwNotFound("Müşteri");
|
||||
}
|
||||
|
||||
deleteClient(actor: DomainActor, id: string) {
|
||||
return this.repositories.clients.remove(requireOwnerScope(actor), id) ?? this.throwNotFound("Müşteri");
|
||||
}
|
||||
|
||||
addClientActivity(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(clientActivityCreateSchema, input);
|
||||
this.requireOwnedClient(scope, value.clientId);
|
||||
return this.repositories.clients.createActivity(scope, { ...value, id: value.id ?? this.id() });
|
||||
}
|
||||
|
||||
listProjects(actor: DomainActor) {
|
||||
if (actor.role === "client") {
|
||||
return this.repositories.projects.listForClient(requireClientScope(actor));
|
||||
}
|
||||
return this.repositories.projects.list(requireOwnerScope(actor));
|
||||
}
|
||||
|
||||
getProject(actor: DomainActor, id: string) {
|
||||
const project = actor.role === "client"
|
||||
? this.repositories.projects.getForClient(requireClientScope(actor), id)
|
||||
: this.repositories.projects.get(requireOwnerScope(actor), id);
|
||||
return project ?? this.throwNotFound("Proje");
|
||||
}
|
||||
|
||||
createProject(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(projectCreateSchema, input);
|
||||
this.assertProjectClient(scope, value.type, value.clientId);
|
||||
return this.repositories.projects.create(scope, { ...value, id: value.id ?? this.id() });
|
||||
}
|
||||
|
||||
updateProject(actor: DomainActor, projectId: string, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const current = this.repositories.projects.get(scope, projectId) ?? this.throwNotFound("Proje");
|
||||
const value = parseDomainInput(projectUpdateSchema, input);
|
||||
this.assertProjectClient(scope, value.type ?? current.type, value.clientId === undefined ? current.clientId : value.clientId);
|
||||
return this.repositories.projects.update(scope, projectId, value) ?? this.throwNotFound("Proje");
|
||||
}
|
||||
|
||||
deleteProject(actor: DomainActor, id: string) {
|
||||
return this.repositories.projects.remove(requireOwnerScope(actor), id) ?? this.throwNotFound("Proje");
|
||||
}
|
||||
|
||||
listTasks(actor: DomainActor, projectId?: string) {
|
||||
if (actor.role === "client") {
|
||||
const scope = requireClientScope(actor);
|
||||
if (projectId) this.getProject(actor, projectId);
|
||||
return this.repositories.tasks.listPublicForClient(scope, projectId);
|
||||
}
|
||||
const scope = requireOwnerScope(actor);
|
||||
const rows = this.repositories.tasks.list(scope);
|
||||
return projectId ? rows.filter((task) => task.projectId === projectId) : rows;
|
||||
}
|
||||
|
||||
createTask(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(taskCreateSchema, input);
|
||||
this.assertTaskRelations(scope, value);
|
||||
const task = this.repositories.tasks.create(scope, { ...value, id: value.id ?? this.id() });
|
||||
if (task.projectId) this.recalculateProjectProgress(scope, task.projectId);
|
||||
return task;
|
||||
}
|
||||
|
||||
updateTask(actor: DomainActor, taskId: string, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const current = this.repositories.tasks.get(scope, taskId) ?? this.throwNotFound("Görev");
|
||||
const value = parseDomainInput(taskUpdateSchema, input);
|
||||
const merged = { ...current, ...value };
|
||||
this.assertTaskRelations(scope, merged);
|
||||
const task = this.repositories.tasks.update(scope, taskId, value) ?? this.throwNotFound("Görev");
|
||||
if (current.projectId) this.recalculateProjectProgress(scope, current.projectId);
|
||||
if (task.projectId && task.projectId !== current.projectId) this.recalculateProjectProgress(scope, task.projectId);
|
||||
return task;
|
||||
}
|
||||
|
||||
deleteTask(actor: DomainActor, taskId: string) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const task = this.repositories.tasks.remove(scope, taskId) ?? this.throwNotFound("Görev");
|
||||
if (task.projectId) this.recalculateProjectProgress(scope, task.projectId);
|
||||
return task;
|
||||
}
|
||||
|
||||
createCalendarEvent(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(calendarEventCreateSchema, input);
|
||||
this.assertTaskRelations(scope, value);
|
||||
return this.repositories.calendar.create(scope, { ...value, id: value.id ?? this.id() });
|
||||
}
|
||||
|
||||
listCalendarEvents(actor: DomainActor) {
|
||||
return this.repositories.calendar.list(requireOwnerScope(actor));
|
||||
}
|
||||
|
||||
updateCalendarEvent(actor: DomainActor, eventId: string, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const current = this.repositories.calendar.get(scope, eventId) ?? this.throwNotFound("Takvim kaydı");
|
||||
const value = parseDomainInput(calendarEventUpdateSchema, input);
|
||||
const merged = { ...current, ...value };
|
||||
if (merged.endsAt && merged.endsAt < merged.startsAt) {
|
||||
throw new DomainError("VALIDATION_ERROR", "Bitiş zamanı başlangıç zamanından önce olamaz.");
|
||||
}
|
||||
this.assertTaskRelations(scope, merged);
|
||||
return this.repositories.calendar.update(scope, eventId, value) ?? this.throwNotFound("Takvim kaydı");
|
||||
}
|
||||
|
||||
deleteCalendarEvent(actor: DomainActor, eventId: string) {
|
||||
return this.repositories.calendar.remove(requireOwnerScope(actor), eventId) ?? this.throwNotFound("Takvim kaydı");
|
||||
}
|
||||
|
||||
createFinanceTransaction(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(financeTransactionCreateSchema, input);
|
||||
this.assertTaskRelations(scope, value);
|
||||
return this.repositories.finance.create(scope, { ...value, id: value.id ?? this.id() });
|
||||
}
|
||||
|
||||
listFinanceTransactions(actor: DomainActor) {
|
||||
return this.repositories.finance.list(requireOwnerScope(actor));
|
||||
}
|
||||
|
||||
updateFinanceTransaction(actor: DomainActor, transactionId: string, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const current = this.repositories.finance.get(scope, transactionId) ?? this.throwNotFound("Finans kaydı");
|
||||
const value = parseDomainInput(financeTransactionUpdateSchema, input);
|
||||
this.assertTaskRelations(scope, { ...current, ...value });
|
||||
return this.repositories.finance.update(scope, transactionId, value) ?? this.throwNotFound("Finans kaydı");
|
||||
}
|
||||
|
||||
deleteFinanceTransaction(actor: DomainActor, transactionId: string) {
|
||||
return this.repositories.finance.remove(requireOwnerScope(actor), transactionId) ?? this.throwNotFound("Finans kaydı");
|
||||
}
|
||||
|
||||
saveJournalEntry(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(journalEntrySchema, input);
|
||||
const existing = this.repositories.journal.getByDate(scope, value.entryDate);
|
||||
if (existing) return this.repositories.journal.updateByDate(scope, value.entryDate, value);
|
||||
return this.repositories.journal.create(scope, { ...value, id: value.id ?? this.id() });
|
||||
}
|
||||
|
||||
listJournalEntries(actor: DomainActor) {
|
||||
return this.repositories.journal.list(requireOwnerScope(actor));
|
||||
}
|
||||
|
||||
deleteJournalEntry(actor: DomainActor, entryId: string) {
|
||||
return this.repositories.journal.remove(requireOwnerScope(actor), entryId) ?? this.throwNotFound("Günlük kaydı");
|
||||
}
|
||||
|
||||
addPlanningSection(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(planningSectionCreateSchema, input);
|
||||
this.requireOwnedProject(scope, value.projectId);
|
||||
return this.repositories.planning.create(scope, { ...value, id: value.id ?? this.id() });
|
||||
}
|
||||
|
||||
updatePlanningSection(actor: DomainActor, sectionId: string, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
if (!this.repositories.planning.get(scope, sectionId)) throw notFound("Planlama bölümü");
|
||||
const value = parseDomainInput(planningSectionUpdateSchema, input);
|
||||
return this.repositories.planning.update(scope, sectionId, value) ?? this.throwNotFound("Planlama bölümü");
|
||||
}
|
||||
|
||||
deletePlanningSection(actor: DomainActor, sectionId: string) {
|
||||
return this.repositories.planning.remove(requireOwnerScope(actor), sectionId) ?? this.throwNotFound("Planlama bölümü");
|
||||
}
|
||||
|
||||
listPlanningSections(actor: DomainActor, projectId: string) {
|
||||
if (actor.role === "client") {
|
||||
const scope = requireClientScope(actor);
|
||||
this.getProject(actor, projectId);
|
||||
return this.repositories.planning.listForClient(scope, projectId);
|
||||
}
|
||||
const scope = requireOwnerScope(actor);
|
||||
this.requireOwnedProject(scope, projectId);
|
||||
return this.repositories.planning.list(scope, projectId);
|
||||
}
|
||||
|
||||
requestRevision(actor: DomainActor, input: unknown) {
|
||||
const scope = requireClientScope(actor);
|
||||
const value = parseDomainInput(revisionCreateSchema, input);
|
||||
const revisionId = value.id ?? this.id();
|
||||
|
||||
return this.db.transaction((tx) => {
|
||||
const project = tx.select().from(projects).where(and(eq(projects.id, value.projectId), eq(projects.clientId, scope.clientId))).get();
|
||||
if (!project) throw notFound("Proje");
|
||||
if (project.status !== "active") {
|
||||
throw new DomainError("INVARIANT_VIOLATION", "Yalnızca aktif projeler revizyon kabul eder.");
|
||||
}
|
||||
const used = tx.select({ value: count() }).from(projectRevisions).where(and(eq(projectRevisions.projectId, project.id), eq(projectRevisions.clientId, scope.clientId), ne(projectRevisions.status, "rejected"))).get()?.value ?? 0;
|
||||
if (used >= project.revisionQuota) throw conflict("Projenin revizyon kotası doldu.");
|
||||
return tx.insert(projectRevisions).values({
|
||||
id: revisionId,
|
||||
ownerUserId: project.ownerUserId,
|
||||
projectId: project.id,
|
||||
clientId: scope.clientId,
|
||||
requestedByUserId: scope.authUserId,
|
||||
description: value.description,
|
||||
}).returning().get();
|
||||
}, { behavior: "immediate" });
|
||||
}
|
||||
|
||||
updateRevisionStatus(actor: DomainActor, revisionId: string, statusInput: unknown) {
|
||||
const status = parseDomainInput(revisionStatusSchema, statusInput);
|
||||
return this.repositories.revisions.updateStatus(requireOwnerScope(actor), revisionId, status) ?? this.throwNotFound("Revizyon");
|
||||
}
|
||||
|
||||
listRevisions(actor: DomainActor, projectId: string) {
|
||||
if (actor.role === "client") {
|
||||
const scope = requireClientScope(actor);
|
||||
this.getProject(actor, projectId);
|
||||
return this.repositories.revisions.listForClient(scope, projectId);
|
||||
}
|
||||
const scope = requireOwnerScope(actor);
|
||||
this.requireOwnedProject(scope, projectId);
|
||||
return this.repositories.revisions.list(scope, projectId);
|
||||
}
|
||||
|
||||
createChatSession(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(chatSessionCreateSchema, input);
|
||||
return this.repositories.chat.createSession(scope, { ...value, id: value.id ?? this.id() });
|
||||
}
|
||||
|
||||
addChatMessage(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(chatMessageCreateSchema, input);
|
||||
if (!this.repositories.chat.getSession(scope, value.sessionId)) throw notFound("Sohbet");
|
||||
if (value.contextJournalEntryIds.length > 0) {
|
||||
const accessible = this.db.select({ value: count() }).from(journalEntries).where(and(eq(journalEntries.ownerUserId, scope.ownerUserId), inArray(journalEntries.id, value.contextJournalEntryIds))).get()?.value ?? 0;
|
||||
if (accessible !== new Set(value.contextJournalEntryIds).size) throw notFound("Günlük kaydı");
|
||||
}
|
||||
const message = this.repositories.chat.createMessage({ ...value, id: value.id ?? this.id() });
|
||||
this.db.update(chatSessions).set({ updatedAt: new Date() }).where(and(eq(chatSessions.id, value.sessionId), eq(chatSessions.ownerUserId, scope.ownerUserId))).run();
|
||||
return message;
|
||||
}
|
||||
|
||||
getAnalytics(actor: DomainActor) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const finance = this.repositories.analytics.summary(scope) ?? { incomeMinor: 0, expenseMinor: 0, plannedMinor: 0 };
|
||||
return {
|
||||
finance: { ...finance, netMinor: finance.incomeMinor - finance.expenseMinor },
|
||||
projectsByStatus: this.repositories.analytics.projectStatusCounts(scope),
|
||||
tasksByStatus: this.repositories.analytics.taskStatusCounts(scope),
|
||||
};
|
||||
}
|
||||
|
||||
createProposal(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(proposalCreateSchema, input);
|
||||
this.assertTaskRelations(scope, value);
|
||||
return this.repositories.business.createProposal(scope, { ...value, id: value.id ?? this.id() });
|
||||
}
|
||||
|
||||
createContract(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(contractCreateSchema, input);
|
||||
if (value.clientId) this.requireOwnedClient(scope, value.clientId);
|
||||
if (value.proposalId && !this.repositories.business.getProposal(scope, value.proposalId)) {
|
||||
throw notFound("Teklif");
|
||||
}
|
||||
return this.repositories.business.createContract(scope, { ...value, id: value.id ?? this.id() });
|
||||
}
|
||||
|
||||
createInvoice(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(invoiceCreateSchema, input);
|
||||
this.assertTaskRelations(scope, value);
|
||||
return this.repositories.business.createInvoice(scope, { ...value, id: value.id ?? this.id() });
|
||||
}
|
||||
|
||||
createSubscription(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(subscriptionCreateSchema, input);
|
||||
return this.repositories.business.createSubscription(scope, { ...value, id: value.id ?? this.id() });
|
||||
}
|
||||
|
||||
private requireOwnedClient(scope: OwnerScope, clientId: string) {
|
||||
return this.repositories.clients.get(scope, clientId) ?? this.throwNotFound("Müşteri");
|
||||
}
|
||||
|
||||
private requireOwnedProject(scope: OwnerScope, projectId: string) {
|
||||
return this.repositories.projects.get(scope, projectId) ?? this.throwNotFound("Proje");
|
||||
}
|
||||
|
||||
private assertProjectClient(scope: OwnerScope, type: string, clientId: string | null | undefined) {
|
||||
if (type === "side_project" && clientId) {
|
||||
throw new DomainError("INVARIANT_VIOLATION", "Yan projeler bir müşteriye bağlanamaz.");
|
||||
}
|
||||
if (clientId) this.requireOwnedClient(scope, clientId);
|
||||
}
|
||||
|
||||
private assertTaskRelations(scope: OwnerScope, value: {
|
||||
clientId?: string | null;
|
||||
projectId?: string | null;
|
||||
taskId?: string | null;
|
||||
sourceJournalEntryId?: string | null;
|
||||
}) {
|
||||
const client = value.clientId ? this.requireOwnedClient(scope, value.clientId) : null;
|
||||
const project = value.projectId ? this.requireOwnedProject(scope, value.projectId) : null;
|
||||
if (project?.clientId && client?.id && project.clientId !== client.id) {
|
||||
throw new DomainError("INVARIANT_VIOLATION", "Proje ve müşteri ilişkisi uyuşmuyor.");
|
||||
}
|
||||
if (project?.clientId && !client) {
|
||||
throw new DomainError("INVARIANT_VIOLATION", "Müşteri projesine bağlı kayıt müşteri kimliğini içermelidir.");
|
||||
}
|
||||
if (value.taskId) {
|
||||
const task = this.repositories.tasks.get(scope, value.taskId) ?? this.throwNotFound("Görev");
|
||||
if (value.projectId && task.projectId && value.projectId !== task.projectId) {
|
||||
throw new DomainError("INVARIANT_VIOLATION", "Etkinlik ve görev proje ilişkisi uyuşmuyor.");
|
||||
}
|
||||
if (value.clientId && task.clientId && value.clientId !== task.clientId) {
|
||||
throw new DomainError("INVARIANT_VIOLATION", "Etkinlik ve görev müşteri ilişkisi uyuşmuyor.");
|
||||
}
|
||||
}
|
||||
if (value.sourceJournalEntryId && !this.repositories.journal.get(scope, value.sourceJournalEntryId)) {
|
||||
throw notFound("Günlük kaydı");
|
||||
}
|
||||
}
|
||||
|
||||
private recalculateProjectProgress(scope: OwnerScope, projectId: string) {
|
||||
const project = this.repositories.projects.get(scope, projectId);
|
||||
if (!project || project.progressType !== "auto") return;
|
||||
const counts = this.repositories.tasks.progressCounts(scope, projectId);
|
||||
const progress = counts?.total ? Math.round((Number(counts.done) / counts.total) * 100) : 0;
|
||||
this.repositories.projects.update(scope, projectId, { progress });
|
||||
}
|
||||
|
||||
private throwNotFound(resource: string): never {
|
||||
throw notFound(resource);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user