854 lines
39 KiB
TypeScript
854 lines
39 KiB
TypeScript
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,
|
||
contractUpdateSchema,
|
||
financeTransactionCreateSchema,
|
||
financeTransactionUpdateSchema,
|
||
invoiceCreateSchema,
|
||
invoiceUpdateSchema,
|
||
journalEntrySchema,
|
||
parseDomainInput,
|
||
planningSectionCreateSchema,
|
||
planningSectionUpdateSchema,
|
||
projectCreateSchema,
|
||
projectUpdateSchema,
|
||
proposalCreateSchema,
|
||
proposalUpdateSchema,
|
||
revisionCreateSchema,
|
||
revisionStatusSchema,
|
||
subscriptionCreateSchema,
|
||
subscriptionUpdateSchema,
|
||
taskCreateSchema,
|
||
taskUpdateSchema,
|
||
calendarEventUpdateSchema,
|
||
} from "../domain/validation";
|
||
import { createDomainRepositories, type DomainRepositories } from "../repositories/domain";
|
||
import { ContentTranslationService, projectBaseFromTranslations } from "../i18n/content";
|
||
import type { ContentTranslationInput } from "../../lib/i18n/content";
|
||
|
||
export class DomainService {
|
||
readonly repositories: DomainRepositories;
|
||
public readonly contentTranslations: ContentTranslationService;
|
||
|
||
constructor(
|
||
private readonly db: DomainDatabase,
|
||
private readonly id: IdGenerator = generateId,
|
||
) {
|
||
this.repositories = createDomainRepositories(db);
|
||
this.contentTranslations = new ContentTranslationService(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 translations = this.getContentTranslations(input);
|
||
const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
|
||
const value = parseDomainInput(
|
||
clientCreateSchema,
|
||
translations ? projectBaseFromTranslations("client", input as Record<string, unknown>, translations, defaultLocale) : input,
|
||
);
|
||
const client = this.repositories.clients.create(scope, { ...value, id: value.id ?? this.id() });
|
||
this.contentTranslations.upsertEntityTranslations("client", client.id, translations);
|
||
return client;
|
||
}
|
||
|
||
updateClient(actor: DomainActor, id: string, input: unknown) {
|
||
const scope = requireOwnerScope(actor);
|
||
const translations = this.getContentTranslations(input);
|
||
const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
|
||
const value = parseDomainInput(
|
||
clientUpdateSchema,
|
||
translations ? projectBaseFromTranslations("client", input as Record<string, unknown>, translations, defaultLocale) : input,
|
||
);
|
||
const client = this.repositories.clients.update(scope, id, value) ?? this.throwNotFound("Müşteri");
|
||
this.contentTranslations.upsertEntityTranslations("client", id, translations);
|
||
return client;
|
||
}
|
||
|
||
deleteClient(actor: DomainActor, id: string) {
|
||
const client = this.repositories.clients.remove(requireOwnerScope(actor), id) ?? this.throwNotFound("Müşteri");
|
||
this.contentTranslations.deleteEntityTranslations("client", id);
|
||
return client;
|
||
}
|
||
|
||
addClientActivity(actor: DomainActor, input: unknown) {
|
||
const scope = requireOwnerScope(actor);
|
||
const translations = this.getContentTranslations(input);
|
||
const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
|
||
const value = parseDomainInput(
|
||
clientActivityCreateSchema,
|
||
translations ? projectBaseFromTranslations("client_activity", input as Record<string, unknown>, translations, defaultLocale) : input,
|
||
);
|
||
this.requireOwnedClient(scope, value.clientId);
|
||
const activity = this.repositories.clients.createActivity(scope, { ...value, id: value.id ?? this.id() });
|
||
this.contentTranslations.upsertEntityTranslations("client_activity", activity.id, translations);
|
||
return activity;
|
||
}
|
||
|
||
listClientActivities(actor: DomainActor, clientId: string) {
|
||
const scope = requireOwnerScope(actor);
|
||
this.requireOwnedClient(scope, clientId);
|
||
return this.repositories.clients.listActivities(scope, clientId);
|
||
}
|
||
|
||
listAllClientActivities(actor: DomainActor) {
|
||
return this.repositories.clients.listAllActivities(requireOwnerScope(actor));
|
||
}
|
||
|
||
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 translations = this.getContentTranslations(input);
|
||
const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
|
||
const value = parseDomainInput(
|
||
projectCreateSchema,
|
||
translations ? projectBaseFromTranslations("project", input as Record<string, unknown>, translations, defaultLocale) : input,
|
||
);
|
||
this.assertProjectClient(scope, value.type, value.clientId);
|
||
const id = value.id ?? this.id();
|
||
const created = this.repositories.projects.create(scope, { ...value, id });
|
||
this.contentTranslations.upsertEntityTranslations("project", created.id, translations);
|
||
return created;
|
||
}
|
||
|
||
updateProject(actor: DomainActor, projectId: string, input: unknown) {
|
||
const scope = requireOwnerScope(actor);
|
||
const current = this.repositories.projects.get(scope, projectId) ?? this.throwNotFound("Proje");
|
||
const translations = this.getContentTranslations(input);
|
||
const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
|
||
const value = parseDomainInput(
|
||
projectUpdateSchema,
|
||
translations ? projectBaseFromTranslations("project", input as Record<string, unknown>, translations, defaultLocale) : input,
|
||
);
|
||
this.assertProjectClient(scope, value.type ?? current.type, value.clientId === undefined ? current.clientId : value.clientId);
|
||
const updated = this.repositories.projects.update(scope, projectId, value) ?? this.throwNotFound("Proje");
|
||
this.contentTranslations.upsertEntityTranslations("project", updated.id, translations);
|
||
if (
|
||
updated.progressType === "auto"
|
||
&& (value.progressType === "auto" || value.progress !== undefined)
|
||
) {
|
||
this.recalculateProjectProgress(scope, projectId);
|
||
return this.repositories.projects.get(scope, projectId) ?? this.throwNotFound("Proje");
|
||
}
|
||
return updated;
|
||
}
|
||
|
||
deleteProject(actor: DomainActor, id: string) {
|
||
const scope = requireOwnerScope(actor);
|
||
const sectionIds = this.repositories.planning.list(scope, id).map((section) => section.id);
|
||
const deleted = this.repositories.projects.remove(scope, id) ?? this.throwNotFound("Proje");
|
||
this.contentTranslations.deleteEntityTranslations("project", id);
|
||
for (const sectionId of sectionIds) {
|
||
this.contentTranslations.deleteEntityTranslations("planning_section", sectionId);
|
||
}
|
||
return deleted;
|
||
}
|
||
|
||
listTasks(actor: DomainActor, projectId?: string) {
|
||
if (actor.role === "client") {
|
||
const scope = requireClientScope(actor);
|
||
this.getClient(actor, scope.clientId);
|
||
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 translations = this.getContentTranslations(input);
|
||
const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
|
||
const value = parseDomainInput(
|
||
taskCreateSchema,
|
||
translations ? projectBaseFromTranslations("task", input as Record<string, unknown>, translations, defaultLocale) : input,
|
||
);
|
||
this.assertTaskRelations(scope, value);
|
||
const task = this.repositories.tasks.create(scope, { ...value, id: value.id ?? this.id() });
|
||
this.contentTranslations.upsertEntityTranslations("task", task.id, translations);
|
||
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 translations = this.getContentTranslations(input);
|
||
const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
|
||
const value = parseDomainInput(
|
||
taskUpdateSchema,
|
||
translations ? projectBaseFromTranslations("task", input as Record<string, unknown>, translations, defaultLocale) : input,
|
||
);
|
||
const merged = { ...current, ...value };
|
||
this.assertTaskRelations(scope, merged);
|
||
const task = this.repositories.tasks.update(scope, taskId, value) ?? this.throwNotFound("Görev");
|
||
this.contentTranslations.upsertEntityTranslations("task", task.id, translations);
|
||
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");
|
||
this.contentTranslations.deleteEntityTranslations("task", taskId);
|
||
if (task.projectId) this.recalculateProjectProgress(scope, task.projectId);
|
||
return task;
|
||
}
|
||
|
||
createCalendarEvent(actor: DomainActor, input: unknown) {
|
||
const scope = requireOwnerScope(actor);
|
||
const translations = this.getContentTranslations(input);
|
||
const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
|
||
const value = parseDomainInput(
|
||
calendarEventCreateSchema,
|
||
translations ? projectBaseFromTranslations("calendar_event", input as Record<string, unknown>, translations, defaultLocale) : input,
|
||
);
|
||
this.assertTaskRelations(scope, value);
|
||
const event = this.repositories.calendar.create(scope, { ...value, id: value.id ?? this.id() });
|
||
this.contentTranslations.upsertEntityTranslations("calendar_event", event.id, translations);
|
||
return event;
|
||
}
|
||
|
||
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 translations = this.getContentTranslations(input);
|
||
const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
|
||
const value = parseDomainInput(
|
||
calendarEventUpdateSchema,
|
||
translations ? projectBaseFromTranslations("calendar_event", input as Record<string, unknown>, translations, defaultLocale) : 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);
|
||
const event = this.repositories.calendar.update(scope, eventId, value) ?? this.throwNotFound("Takvim kaydı");
|
||
this.contentTranslations.upsertEntityTranslations("calendar_event", eventId, translations);
|
||
return event;
|
||
}
|
||
|
||
deleteCalendarEvent(actor: DomainActor, eventId: string) {
|
||
const event = this.repositories.calendar.remove(requireOwnerScope(actor), eventId) ?? this.throwNotFound("Takvim kaydı");
|
||
this.contentTranslations.deleteEntityTranslations("calendar_event", eventId);
|
||
return event;
|
||
}
|
||
|
||
createFinanceTransaction(actor: DomainActor, input: unknown) {
|
||
const scope = requireOwnerScope(actor);
|
||
const translations = this.getContentTranslations(input);
|
||
const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
|
||
const value = parseDomainInput(
|
||
financeTransactionCreateSchema,
|
||
translations ? projectBaseFromTranslations("finance_transaction", input as Record<string, unknown>, translations, defaultLocale) : input,
|
||
);
|
||
this.assertTaskRelations(scope, value);
|
||
const transaction = this.repositories.finance.create(scope, { ...value, id: value.id ?? this.id() });
|
||
this.contentTranslations.upsertEntityTranslations("finance_transaction", transaction.id, translations);
|
||
return transaction;
|
||
}
|
||
|
||
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 translations = this.getContentTranslations(input);
|
||
const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
|
||
const value = parseDomainInput(
|
||
financeTransactionUpdateSchema,
|
||
translations ? projectBaseFromTranslations("finance_transaction", input as Record<string, unknown>, translations, defaultLocale) : input,
|
||
);
|
||
this.assertTaskRelations(scope, { ...current, ...value });
|
||
const transaction = this.repositories.finance.update(scope, transactionId, value) ?? this.throwNotFound("Finans kaydı");
|
||
this.contentTranslations.upsertEntityTranslations("finance_transaction", transaction.id, translations);
|
||
return transaction;
|
||
}
|
||
|
||
deleteFinanceTransaction(actor: DomainActor, transactionId: string) {
|
||
const transaction = this.repositories.finance.remove(requireOwnerScope(actor), transactionId) ?? this.throwNotFound("Finans kaydı");
|
||
this.contentTranslations.deleteEntityTranslations("finance_transaction", transactionId);
|
||
return transaction;
|
||
}
|
||
|
||
saveJournalEntry(actor: DomainActor, input: unknown) {
|
||
const scope = requireOwnerScope(actor);
|
||
const translations = this.getContentTranslations(input);
|
||
const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
|
||
const value = parseDomainInput(
|
||
journalEntrySchema,
|
||
translations ? projectBaseFromTranslations("journal_entry", input as Record<string, unknown>, translations, defaultLocale) : input,
|
||
);
|
||
let entry = this.repositories.journal.getByDate(scope, value.entryDate);
|
||
if (entry) {
|
||
entry = this.repositories.journal.updateByDate(scope, value.entryDate, value);
|
||
} else {
|
||
entry = this.repositories.journal.create(scope, { ...value, id: value.id ?? this.id() });
|
||
}
|
||
if (entry) {
|
||
this.contentTranslations.upsertEntityTranslations("journal_entry", entry.id, translations);
|
||
}
|
||
return entry;
|
||
}
|
||
|
||
listJournalEntries(actor: DomainActor) {
|
||
return this.repositories.journal.list(requireOwnerScope(actor));
|
||
}
|
||
|
||
updateJournalEntry(actor: DomainActor, entryId: string, input: unknown) {
|
||
const scope = requireOwnerScope(actor);
|
||
const translations = this.getContentTranslations(input);
|
||
const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
|
||
const value = parseDomainInput(
|
||
journalEntrySchema.omit({ id: true }),
|
||
translations ? projectBaseFromTranslations("journal_entry", input as Record<string, unknown>, translations, defaultLocale) : input,
|
||
);
|
||
const conflicting = this.repositories.journal.getByDate(scope, value.entryDate);
|
||
if (conflicting && conflicting.id !== entryId) {
|
||
throw conflict("Bu tarih için zaten bir günlük kaydı var.");
|
||
}
|
||
const entry = this.repositories.journal.update(scope, entryId, value) ?? this.throwNotFound("Günlük kaydı");
|
||
this.contentTranslations.upsertEntityTranslations("journal_entry", entry.id, translations);
|
||
return entry;
|
||
}
|
||
|
||
deleteJournalEntry(actor: DomainActor, entryId: string) {
|
||
const entry = this.repositories.journal.remove(requireOwnerScope(actor), entryId) ?? this.throwNotFound("Günlük kaydı");
|
||
this.contentTranslations.deleteEntityTranslations("journal_entry", entryId);
|
||
return entry;
|
||
}
|
||
|
||
addPlanningSection(actor: DomainActor, input: unknown) {
|
||
const scope = requireOwnerScope(actor);
|
||
const translations = this.getContentTranslations(input);
|
||
const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
|
||
const value = parseDomainInput(
|
||
planningSectionCreateSchema,
|
||
translations ? projectBaseFromTranslations("planning_section", input as Record<string, unknown>, translations, defaultLocale) : input,
|
||
);
|
||
this.requireOwnedProject(scope, value.projectId);
|
||
const section = this.repositories.planning.create(scope, { ...value, id: value.id ?? this.id() });
|
||
this.contentTranslations.upsertEntityTranslations("planning_section", section.id, translations);
|
||
return section;
|
||
}
|
||
|
||
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 translations = this.getContentTranslations(input);
|
||
const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
|
||
const value = parseDomainInput(
|
||
planningSectionUpdateSchema,
|
||
translations ? projectBaseFromTranslations("planning_section", input as Record<string, unknown>, translations, defaultLocale) : input,
|
||
);
|
||
const section = this.repositories.planning.update(scope, sectionId, value) ?? this.throwNotFound("Planlama bölümü");
|
||
this.contentTranslations.upsertEntityTranslations("planning_section", section.id, translations);
|
||
return section;
|
||
}
|
||
|
||
deletePlanningSection(actor: DomainActor, sectionId: string) {
|
||
const section = this.repositories.planning.remove(requireOwnerScope(actor), sectionId) ?? this.throwNotFound("Planlama bölümü");
|
||
this.contentTranslations.deleteEntityTranslations("planning_section", sectionId);
|
||
return section;
|
||
}
|
||
|
||
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);
|
||
this.getClient(actor, scope.clientId);
|
||
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,
|
||
sourceLocale: value.sourceLocale,
|
||
}).returning().get();
|
||
}, { behavior: "immediate" });
|
||
}
|
||
|
||
updateRevisionStatus(actor: DomainActor, revisionId: string, statusInput: unknown, projectId?: string) {
|
||
const scope = requireOwnerScope(actor);
|
||
if (projectId) {
|
||
this.requireOwnedProject(scope, projectId);
|
||
if (!this.repositories.revisions.list(scope, projectId).some((revision) => revision.id === revisionId)) {
|
||
throw notFound("Revizyon");
|
||
}
|
||
}
|
||
const status = parseDomainInput(revisionStatusSchema, statusInput);
|
||
return this.repositories.revisions.updateStatus(scope, 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);
|
||
}
|
||
|
||
listPortalRevisions(actor: DomainActor) {
|
||
const scope = requireClientScope(actor);
|
||
this.getClient(actor, scope.clientId);
|
||
return this.repositories.revisions.listAllForClient(scope);
|
||
}
|
||
|
||
getRevisionAllowance(actor: DomainActor, projectId: string) {
|
||
const scope = requireClientScope(actor);
|
||
const project = this.getProject(actor, projectId);
|
||
const used = this.repositories.revisions
|
||
.listForClient(scope, projectId)
|
||
.filter((revision) => revision.status !== "rejected")
|
||
.length;
|
||
const remaining = Math.max(project.revisionQuota - used, 0);
|
||
|
||
return {
|
||
quota: project.revisionQuota,
|
||
used,
|
||
remaining,
|
||
canRequest: project.status === "active" && remaining > 0,
|
||
};
|
||
}
|
||
|
||
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() });
|
||
}
|
||
|
||
listChatSessions(actor: DomainActor) {
|
||
return this.repositories.chat.listSessions(requireOwnerScope(actor));
|
||
}
|
||
|
||
getChatSession(actor: DomainActor, sessionId: string) {
|
||
return this.repositories.chat.getSession(requireOwnerScope(actor), sessionId)
|
||
?? this.throwNotFound("Sohbet");
|
||
}
|
||
|
||
listChatMessages(actor: DomainActor, sessionId: string) {
|
||
const scope = requireOwnerScope(actor);
|
||
if (!this.repositories.chat.getSession(scope, sessionId)) throw notFound("Sohbet");
|
||
return this.repositories.chat.listMessages(scope, sessionId);
|
||
}
|
||
|
||
deleteChatSession(actor: DomainActor, sessionId: string) {
|
||
return this.repositories.chat.removeSession(requireOwnerScope(actor), sessionId)
|
||
?? this.throwNotFound("Sohbet");
|
||
}
|
||
|
||
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),
|
||
};
|
||
}
|
||
|
||
getFreelancerDashboard(
|
||
actor: DomainActor,
|
||
range: { startDate: string; endDate: string; startAt: Date; endAt: Date },
|
||
) {
|
||
const scope = requireOwnerScope(actor);
|
||
const finance = this.repositories.finance.listInRange(scope, range.startDate, range.endDate);
|
||
const journal = this.repositories.journal.listInRange(scope, range.startDate, range.endDate);
|
||
const projectsByStatus = this.repositories.analytics.projectStatusCounts(scope);
|
||
const tasksByStatus = this.repositories.analytics.taskStatusCountsInRange(
|
||
scope,
|
||
range.startAt,
|
||
range.endAt,
|
||
);
|
||
|
||
const financeByDate = new Map<string, { income: number; expense: number }>();
|
||
let incomeMinor = 0;
|
||
let expenseMinor = 0;
|
||
for (const transaction of finance) {
|
||
if (transaction.paymentStatus !== "paid") continue;
|
||
const current = financeByDate.get(transaction.transactionDate) ?? { income: 0, expense: 0 };
|
||
if (transaction.type === "income") {
|
||
current.income += transaction.amountMinor;
|
||
incomeMinor += transaction.amountMinor;
|
||
} else {
|
||
current.expense += transaction.amountMinor;
|
||
expenseMinor += transaction.amountMinor;
|
||
}
|
||
financeByDate.set(transaction.transactionDate, current);
|
||
}
|
||
|
||
const moodValues = journal.flatMap((entry) =>
|
||
entry.moodScore == null ? [] : [entry.moodScore],
|
||
);
|
||
|
||
return {
|
||
metrics: {
|
||
netProfit: (incomeMinor - expenseMinor) / 100,
|
||
activeProjectsCount:
|
||
projectsByStatus.find((item) => item.status === "active")?.value ?? 0,
|
||
completedTasksCount:
|
||
tasksByStatus.find((item) => item.status === "done")?.value ?? 0,
|
||
avgMood: moodValues.length
|
||
? (moodValues.reduce((sum, value) => sum + value, 0) / moodValues.length).toFixed(1)
|
||
: "0.0",
|
||
financeTrend: Array.from(financeByDate, ([date, value]) => ({
|
||
date,
|
||
income: value.income / 100,
|
||
expense: value.expense / 100,
|
||
})),
|
||
moodTrend: journal.map((entry) => ({
|
||
date: entry.entryDate,
|
||
mood: entry.moodScore ?? 0,
|
||
energy: entry.energyScore ?? 0,
|
||
})),
|
||
},
|
||
projects: this.repositories.projects.recent(scope, 5),
|
||
clients: this.repositories.clients.recent(scope, 5),
|
||
};
|
||
}
|
||
|
||
getFreelancerAnalytics(
|
||
actor: DomainActor,
|
||
range: { startDate: string; endDate: string; startAt: Date; endAt: Date },
|
||
) {
|
||
const scope = requireOwnerScope(actor);
|
||
const tasksByStatus = this.repositories.analytics.taskStatusCountsInRange(
|
||
scope,
|
||
range.startAt,
|
||
range.endAt,
|
||
);
|
||
const taskCount = (status: string) =>
|
||
tasksByStatus.find((item) => item.status === status)?.value ?? 0;
|
||
|
||
return {
|
||
projectIncomeData: this.repositories.analytics
|
||
.projectIncomeInRange(scope, range.startDate, range.endDate)
|
||
.map((item) => ({ name: item.name, value: Number(item.amountMinor) / 100 })),
|
||
completedTasks: taskCount("done"),
|
||
activeTasks: taskCount("todo") + taskCount("in_progress"),
|
||
};
|
||
}
|
||
|
||
createProposal(actor: DomainActor, input: unknown) {
|
||
const scope = requireOwnerScope(actor);
|
||
const { translations, ...rawInput } = input as Record<string, unknown>;
|
||
const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
|
||
const value = parseDomainInput(
|
||
proposalCreateSchema,
|
||
translations ? projectBaseFromTranslations("proposal", rawInput, translations as ContentTranslationInput, defaultLocale) : input,
|
||
);
|
||
this.assertTaskRelations(scope, value);
|
||
const proposal = this.repositories.business.createProposal(scope, { ...value, id: value.id ?? this.id() });
|
||
this.contentTranslations.upsertEntityTranslations("proposal", proposal.id, translations as ContentTranslationInput | undefined);
|
||
return proposal;
|
||
}
|
||
|
||
listProposals(actor: DomainActor) {
|
||
return this.repositories.business.listProposals(requireOwnerScope(actor));
|
||
}
|
||
|
||
getProposal(actor: DomainActor, proposalId: string) {
|
||
return this.repositories.business.getProposal(requireOwnerScope(actor), proposalId)
|
||
?? this.throwNotFound("Teklif");
|
||
}
|
||
|
||
updateProposal(actor: DomainActor, proposalId: string, input: unknown) {
|
||
const scope = requireOwnerScope(actor);
|
||
const current = this.repositories.business.getProposal(scope, proposalId)
|
||
?? this.throwNotFound("Teklif");
|
||
const { translations, ...rawInput } = input as Record<string, unknown>;
|
||
const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
|
||
const value = parseDomainInput(
|
||
proposalUpdateSchema,
|
||
translations ? projectBaseFromTranslations("proposal", rawInput, translations as ContentTranslationInput, defaultLocale) : input,
|
||
);
|
||
this.assertTaskRelations(scope, { ...current, ...value });
|
||
const proposal = this.repositories.business.updateProposal(scope, proposalId, value)
|
||
?? this.throwNotFound("Teklif");
|
||
this.contentTranslations.upsertEntityTranslations("proposal", proposal.id, translations as ContentTranslationInput | undefined);
|
||
return proposal;
|
||
}
|
||
|
||
deleteProposal(actor: DomainActor, proposalId: string) {
|
||
const proposal = this.repositories.business.removeProposal(requireOwnerScope(actor), proposalId)
|
||
?? this.throwNotFound("Teklif");
|
||
this.contentTranslations.deleteEntityTranslations("proposal", proposalId);
|
||
return proposal;
|
||
}
|
||
|
||
createContract(actor: DomainActor, input: unknown) {
|
||
const scope = requireOwnerScope(actor);
|
||
const value = parseDomainInput(contractCreateSchema, input);
|
||
this.assertContractRelations(scope, value);
|
||
return this.repositories.business.createContract(scope, { ...value, id: value.id ?? this.id() });
|
||
}
|
||
|
||
listContracts(actor: DomainActor) {
|
||
return this.repositories.business.listContracts(requireOwnerScope(actor));
|
||
}
|
||
|
||
getContract(actor: DomainActor, contractId: string) {
|
||
return this.repositories.business.getContract(requireOwnerScope(actor), contractId)
|
||
?? this.throwNotFound("Sözleşme");
|
||
}
|
||
|
||
updateContract(actor: DomainActor, contractId: string, input: unknown) {
|
||
const scope = requireOwnerScope(actor);
|
||
const current = this.repositories.business.getContract(scope, contractId)
|
||
?? this.throwNotFound("Sözleşme");
|
||
const value = parseDomainInput(contractUpdateSchema, input);
|
||
this.assertContractRelations(scope, { ...current, ...value });
|
||
return this.repositories.business.updateContract(scope, contractId, value)
|
||
?? this.throwNotFound("Sözleşme");
|
||
}
|
||
|
||
deleteContract(actor: DomainActor, contractId: string) {
|
||
return this.repositories.business.removeContract(requireOwnerScope(actor), contractId)
|
||
?? this.throwNotFound("Sözleşme");
|
||
}
|
||
|
||
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() });
|
||
}
|
||
|
||
listInvoices(actor: DomainActor) {
|
||
return this.repositories.business.listInvoices(requireOwnerScope(actor));
|
||
}
|
||
|
||
getInvoice(actor: DomainActor, invoiceId: string) {
|
||
return this.repositories.business.getInvoice(requireOwnerScope(actor), invoiceId)
|
||
?? this.throwNotFound("Fatura");
|
||
}
|
||
|
||
updateInvoice(actor: DomainActor, invoiceId: string, input: unknown) {
|
||
const scope = requireOwnerScope(actor);
|
||
const current = this.repositories.business.getInvoice(scope, invoiceId)
|
||
?? this.throwNotFound("Fatura");
|
||
const value = parseDomainInput(invoiceUpdateSchema, input);
|
||
this.assertTaskRelations(scope, { ...current, ...value });
|
||
return this.repositories.business.updateInvoice(scope, invoiceId, value)
|
||
?? this.throwNotFound("Fatura");
|
||
}
|
||
|
||
deleteInvoice(actor: DomainActor, invoiceId: string) {
|
||
return this.repositories.business.removeInvoice(requireOwnerScope(actor), invoiceId)
|
||
?? this.throwNotFound("Fatura");
|
||
}
|
||
|
||
createSubscription(actor: DomainActor, input: unknown) {
|
||
const scope = requireOwnerScope(actor);
|
||
const { translations, ...rawInput } = input as Record<string, unknown>;
|
||
const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
|
||
const value = parseDomainInput(
|
||
subscriptionCreateSchema,
|
||
translations ? projectBaseFromTranslations("subscription", rawInput, translations as ContentTranslationInput, defaultLocale) : input,
|
||
);
|
||
const subscription = this.repositories.business.createSubscription(scope, { ...value, id: value.id ?? this.id() });
|
||
this.contentTranslations.upsertEntityTranslations("subscription", subscription.id, translations as ContentTranslationInput | undefined);
|
||
return subscription;
|
||
}
|
||
|
||
listSubscriptions(actor: DomainActor) {
|
||
return this.repositories.business.listSubscriptions(requireOwnerScope(actor));
|
||
}
|
||
|
||
getSubscription(actor: DomainActor, subscriptionId: string) {
|
||
return this.repositories.business.getSubscription(requireOwnerScope(actor), subscriptionId)
|
||
?? this.throwNotFound("Abonelik");
|
||
}
|
||
|
||
updateSubscription(actor: DomainActor, subscriptionId: string, input: unknown) {
|
||
const scope = requireOwnerScope(actor);
|
||
const { translations, ...rawInput } = input as Record<string, unknown>;
|
||
const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
|
||
const value = parseDomainInput(
|
||
subscriptionUpdateSchema,
|
||
translations ? projectBaseFromTranslations("subscription", rawInput, translations as ContentTranslationInput, defaultLocale) : input,
|
||
);
|
||
if (!this.repositories.business.getSubscription(scope, subscriptionId)) {
|
||
throw notFound("Abonelik");
|
||
}
|
||
const subscription = this.repositories.business.updateSubscription(scope, subscriptionId, value)
|
||
?? this.throwNotFound("Abonelik");
|
||
this.contentTranslations.upsertEntityTranslations("subscription", subscription.id, translations as ContentTranslationInput | undefined);
|
||
return subscription;
|
||
}
|
||
|
||
deleteSubscription(actor: DomainActor, subscriptionId: string) {
|
||
const subscription = this.repositories.business.removeSubscription(requireOwnerScope(actor), subscriptionId)
|
||
?? this.throwNotFound("Abonelik");
|
||
this.contentTranslations.deleteEntityTranslations("subscription", subscriptionId);
|
||
return subscription;
|
||
}
|
||
|
||
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 assertContractRelations(scope: OwnerScope, value: {
|
||
clientId?: string | null;
|
||
proposalId?: string | null;
|
||
}) {
|
||
const client = value.clientId ? this.requireOwnedClient(scope, value.clientId) : null;
|
||
const proposal = value.proposalId
|
||
? this.repositories.business.getProposal(scope, value.proposalId) ?? this.throwNotFound("Teklif")
|
||
: null;
|
||
if (proposal?.clientId && client?.id && proposal.clientId !== client.id) {
|
||
throw new DomainError("INVARIANT_VIOLATION", "Teklif ve sözleşme müşterisi uyuşmuyor.");
|
||
}
|
||
if (proposal?.clientId && !client) {
|
||
throw new DomainError(
|
||
"INVARIANT_VIOLATION",
|
||
"Müşterili tekliften üretilen sözleşme müşteri kimliğini içermelidir.",
|
||
);
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
private getContentTranslations(input: unknown): ContentTranslationInput | undefined {
|
||
if (!input || typeof input !== "object") return undefined;
|
||
const translations = (input as { translations?: unknown }).translations;
|
||
if (!translations || typeof translations !== "object") return undefined;
|
||
return translations as ContentTranslationInput;
|
||
}
|
||
}
|