feat(backend): migrate freelancer and portal runtimes

This commit is contained in:
poyrazavsever
2026-07-17 00:16:38 +03:00
parent 561af11b70
commit 678c0236db
41 changed files with 5293 additions and 2324 deletions
@@ -0,0 +1,10 @@
CREATE TABLE `user_ai_settings` (
`owner_user_id` text PRIMARY KEY NOT NULL,
`provider` text DEFAULT 'gemini' NOT NULL,
`model` text,
`encrypted_api_key` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
CONSTRAINT "user_ai_settings_provider_check" CHECK("user_ai_settings"."provider" in ('gemini', 'openai', 'groq', 'ollama'))
);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -36,6 +36,13 @@
"when": 1784210311370,
"tag": "0004_fancy_baron_zemo",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1784234752708,
"tag": "0005_brief_black_bolt",
"breakpoints": true
}
]
}
+1
View File
@@ -1,4 +1,5 @@
export * from "./auth";
export * from "./domain";
export * from "./runtime";
export * from "./settings";
export * from "./storage";
+25
View File
@@ -0,0 +1,25 @@
import { sql } from "drizzle-orm";
import { check, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { user } from "./auth";
export type AiProvider = "gemini" | "openai" | "groq" | "ollama";
export const userAiSettings = sqliteTable(
"user_ai_settings",
{
ownerUserId: text("owner_user_id")
.primaryKey()
.references(() => user.id, { onDelete: "cascade" }),
provider: text("provider").$type<AiProvider>().default("gemini").notNull(),
model: text("model"),
encryptedApiKey: text("encrypted_api_key"),
createdAt: text("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
updatedAt: text("updated_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
},
(table) => [
check(
"user_ai_settings_provider_check",
sql`${table.provider} in ('gemini', 'openai', 'groq', 'ollama')`,
),
],
);
+70 -6
View File
@@ -49,7 +49,17 @@ export const clientCreateSchema = z.object({
nextFollowUpDate: optionalDate,
notes: optionalText(10_000),
});
export const clientUpdateSchema = clientCreateSchema.omit({ id: true }).partial();
export const clientUpdateSchema = z.object({
name: z.string().trim().min(1).max(160).optional(),
companyName: optionalText(160),
email: z.email().nullable().optional(),
phone: optionalText(40),
website: z.url().nullable().optional(),
status: z.enum(clientStatuses).optional(),
pipelineStage: z.enum(clientPipelineStages).optional(),
nextFollowUpDate: optionalDate,
notes: optionalText(10_000),
});
export const clientActivityCreateSchema = z.object({
id: resourceIdSchema.optional(),
@@ -77,7 +87,22 @@ export const projectCreateSchema = z.object({
legacyCoverImagePath: optionalText(1_000),
coverImageAlt: optionalText(500),
});
export const projectUpdateSchema = projectCreateSchema.omit({ id: true }).partial();
export const projectUpdateSchema = z.object({
clientId: optionalId,
name: z.string().trim().min(1).max(200).optional(),
type: z.enum(projectTypes).optional(),
description: optionalText(20_000),
status: z.enum(projectStatuses).optional(),
startDate: optionalDate,
dueDate: optionalDate,
budgetAmountMinor: minorAmountSchema.nullable().optional(),
currency: currencySchema.optional(),
progress: z.number().int().min(0).max(100).optional(),
progressType: z.enum(projectProgressTypes).optional(),
revisionQuota: z.number().int().min(0).max(10_000).optional(),
legacyCoverImagePath: optionalText(1_000),
coverImageAlt: optionalText(500),
});
export const taskCreateSchema = z.object({
id: resourceIdSchema.optional(),
@@ -95,7 +120,21 @@ export const taskCreateSchema = z.object({
aiGenerated: z.boolean().default(false),
isPublicToClient: z.boolean().default(false),
});
export const taskUpdateSchema = taskCreateSchema.omit({ id: true }).partial();
export const taskUpdateSchema = z.object({
clientId: optionalId,
projectId: optionalId,
sourceJournalEntryId: optionalId,
title: z.string().trim().min(1).max(300).optional(),
description: optionalText(20_000),
status: z.enum(taskStatuses).optional(),
priority: z.enum(taskPriorities).optional(),
scheduledDate: optionalDate,
dueAt: z.date().nullable().optional(),
estimatedMinutes: z.number().int().min(0).nullable().optional(),
actualMinutes: z.number().int().min(0).nullable().optional(),
aiGenerated: z.boolean().optional(),
isPublicToClient: z.boolean().optional(),
});
const calendarEventBaseSchema = z.object({
id: resourceIdSchema.optional(),
@@ -113,7 +152,16 @@ export const calendarEventCreateSchema = calendarEventBaseSchema
message: "Bitiş zamanı başlangıç zamanından önce olamaz.",
path: ["endsAt"],
});
export const calendarEventUpdateSchema = calendarEventBaseSchema.omit({ id: true }).partial();
export const calendarEventUpdateSchema = z.object({
clientId: optionalId,
projectId: optionalId,
taskId: optionalId,
title: z.string().trim().min(1).max(300).optional(),
description: optionalText(20_000),
type: z.enum(calendarEventTypes).optional(),
startsAt: z.date().optional(),
endsAt: z.date().nullable().optional(),
});
export const financeTransactionCreateSchema = z.object({
id: resourceIdSchema.optional(),
@@ -127,7 +175,17 @@ export const financeTransactionCreateSchema = z.object({
paymentStatus: z.enum(paymentStatuses).default("planned"),
description: optionalText(10_000),
});
export const financeTransactionUpdateSchema = financeTransactionCreateSchema.omit({ id: true }).partial();
export const financeTransactionUpdateSchema = z.object({
clientId: optionalId,
projectId: optionalId,
type: z.enum(financeTransactionTypes).optional(),
amountMinor: minorAmountSchema.optional(),
currency: currencySchema.optional(),
transactionDate: businessDateSchema.optional(),
category: optionalText(160),
paymentStatus: z.enum(paymentStatuses).optional(),
description: optionalText(10_000),
});
export const journalEntrySchema = z.object({
id: resourceIdSchema.optional(),
@@ -149,7 +207,13 @@ export const planningSectionCreateSchema = z.object({
metadata: z.record(z.string(), z.unknown()).default({}),
sortOrder: z.number().int().min(0).default(0),
});
export const planningSectionUpdateSchema = planningSectionCreateSchema.omit({ id: true, projectId: true }).partial();
export const planningSectionUpdateSchema = z.object({
category: z.enum(planningSectionCategories).optional(),
title: z.string().trim().min(1).max(300).optional(),
content: optionalText(50_000),
metadata: z.record(z.string(), z.unknown()).optional(),
sortOrder: z.number().int().min(0).optional(),
});
export const revisionCreateSchema = z.object({
id: resourceIdSchema.optional(),
+1
View File
@@ -176,6 +176,7 @@ export class FileService {
const scope = requireClientScope(actor);
if (file.kind === "avatar" && file.authUserId === scope.authUserId) return;
if (file.kind === "project_asset" && file.visibility === "portal" && file.projectId) {
this.getClientOwner(actor);
const project = this.db
.select({ id: projects.id })
.from(projects)
+78 -3
View File
@@ -1,4 +1,4 @@
import { and, asc, count, desc, eq, ne, sql } from "drizzle-orm";
import { and, asc, count, desc, eq, gte, lte, ne, sql } from "drizzle-orm";
import {
calendarEvents,
chatMessages,
@@ -24,6 +24,8 @@ export function createDomainRepositories(db: DomainDatabase) {
clients: {
list: (scope: OwnerScope) =>
db.select().from(clients).where(eq(clients.ownerUserId, scope.ownerUserId)).orderBy(desc(clients.updatedAt)).all(),
recent: (scope: OwnerScope, limit: number) =>
db.select().from(clients).where(eq(clients.ownerUserId, scope.ownerUserId)).orderBy(desc(clients.createdAt)).limit(limit).all(),
get: (scope: OwnerScope, id: string) =>
db.select().from(clients).where(and(eq(clients.id, id), eq(clients.ownerUserId, scope.ownerUserId))).get(),
getByPortalScope: (scope: ClientScope) =>
@@ -36,18 +38,45 @@ export function createDomainRepositories(db: DomainDatabase) {
db.delete(clients).where(and(eq(clients.id, id), eq(clients.ownerUserId, scope.ownerUserId))).returning().get(),
listActivities: (scope: OwnerScope, clientId: string) =>
db.select().from(clientActivities).where(and(eq(clientActivities.ownerUserId, scope.ownerUserId), eq(clientActivities.clientId, clientId))).orderBy(desc(clientActivities.activityDate)).all(),
listAllActivities: (scope: OwnerScope) =>
db.select().from(clientActivities).where(eq(clientActivities.ownerUserId, scope.ownerUserId)).orderBy(desc(clientActivities.activityDate)).all(),
createActivity: (scope: OwnerScope, value: Omit<typeof clientActivities.$inferInsert, "ownerUserId">) =>
db.insert(clientActivities).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
},
projects: {
list: (scope: OwnerScope) =>
db.select().from(projects).where(eq(projects.ownerUserId, scope.ownerUserId)).orderBy(desc(projects.updatedAt)).all(),
recent: (scope: OwnerScope, limit: number) =>
db.select().from(projects).where(eq(projects.ownerUserId, scope.ownerUserId)).orderBy(desc(projects.createdAt)).limit(limit).all(),
get: (scope: OwnerScope, id: string) =>
db.select().from(projects).where(and(eq(projects.id, id), eq(projects.ownerUserId, scope.ownerUserId))).get(),
getForClient: (scope: ClientScope, id: string) =>
db.select().from(projects).where(and(eq(projects.id, id), eq(projects.clientId, scope.clientId))).get(),
db.select({ project: projects })
.from(projects)
.innerJoin(
clients,
and(
eq(projects.clientId, clients.id),
eq(clients.id, scope.clientId),
eq(clients.authUserId, scope.authUserId),
),
)
.where(eq(projects.id, id))
.get()?.project,
listForClient: (scope: ClientScope) =>
db.select().from(projects).where(eq(projects.clientId, scope.clientId)).orderBy(desc(projects.updatedAt)).all(),
db.select({ project: projects })
.from(projects)
.innerJoin(
clients,
and(
eq(projects.clientId, clients.id),
eq(clients.id, scope.clientId),
eq(clients.authUserId, scope.authUserId),
),
)
.orderBy(desc(projects.updatedAt))
.all()
.map(({ project }) => project),
create: (scope: OwnerScope, value: Omit<typeof projects.$inferInsert, "ownerUserId">) =>
db.insert(projects).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
update: (scope: OwnerScope, id: string, value: Partial<typeof projects.$inferInsert>) =>
@@ -91,6 +120,8 @@ export function createDomainRepositories(db: DomainDatabase) {
},
finance: {
list: (scope: OwnerScope) => db.select().from(financeTransactions).where(eq(financeTransactions.ownerUserId, scope.ownerUserId)).orderBy(desc(financeTransactions.transactionDate)).all(),
listInRange: (scope: OwnerScope, startDate: string, endDate: string) =>
db.select().from(financeTransactions).where(and(eq(financeTransactions.ownerUserId, scope.ownerUserId), gte(financeTransactions.transactionDate, startDate), lte(financeTransactions.transactionDate, endDate))).orderBy(asc(financeTransactions.transactionDate)).all(),
get: (scope: OwnerScope, id: string) => db.select().from(financeTransactions).where(and(eq(financeTransactions.id, id), eq(financeTransactions.ownerUserId, scope.ownerUserId))).get(),
create: (scope: OwnerScope, value: Omit<typeof financeTransactions.$inferInsert, "ownerUserId">) => db.insert(financeTransactions).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
update: (scope: OwnerScope, id: string, value: Partial<typeof financeTransactions.$inferInsert>) => db.update(financeTransactions).set(value).where(and(eq(financeTransactions.id, id), eq(financeTransactions.ownerUserId, scope.ownerUserId))).returning().get(),
@@ -98,16 +129,39 @@ export function createDomainRepositories(db: DomainDatabase) {
},
journal: {
list: (scope: OwnerScope) => db.select().from(journalEntries).where(eq(journalEntries.ownerUserId, scope.ownerUserId)).orderBy(desc(journalEntries.entryDate)).all(),
listInRange: (scope: OwnerScope, startDate: string, endDate: string) =>
db.select().from(journalEntries).where(and(eq(journalEntries.ownerUserId, scope.ownerUserId), gte(journalEntries.entryDate, startDate), lte(journalEntries.entryDate, endDate))).orderBy(asc(journalEntries.entryDate)).all(),
getByDate: (scope: OwnerScope, entryDate: string) => db.select().from(journalEntries).where(and(eq(journalEntries.ownerUserId, scope.ownerUserId), eq(journalEntries.entryDate, entryDate))).get(),
get: (scope: OwnerScope, id: string) => db.select().from(journalEntries).where(and(eq(journalEntries.id, id), eq(journalEntries.ownerUserId, scope.ownerUserId))).get(),
create: (scope: OwnerScope, value: Omit<typeof journalEntries.$inferInsert, "ownerUserId">) => db.insert(journalEntries).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
updateByDate: (scope: OwnerScope, entryDate: string, value: Partial<typeof journalEntries.$inferInsert>) => db.update(journalEntries).set(value).where(and(eq(journalEntries.ownerUserId, scope.ownerUserId), eq(journalEntries.entryDate, entryDate))).returning().get(),
update: (scope: OwnerScope, id: string, value: Partial<typeof journalEntries.$inferInsert>) => db.update(journalEntries).set(value).where(and(eq(journalEntries.id, id), eq(journalEntries.ownerUserId, scope.ownerUserId))).returning().get(),
remove: (scope: OwnerScope, id: string) => db.delete(journalEntries).where(and(eq(journalEntries.id, id), eq(journalEntries.ownerUserId, scope.ownerUserId))).returning().get(),
},
revisions: {
list: (scope: OwnerScope, projectId: string) => db.select().from(projectRevisions).where(and(eq(projectRevisions.ownerUserId, scope.ownerUserId), eq(projectRevisions.projectId, projectId))).orderBy(desc(projectRevisions.createdAt)).all(),
updateStatus: (scope: OwnerScope, id: string, status: typeof projectRevisions.$inferInsert.status) => db.update(projectRevisions).set({ status }).where(and(eq(projectRevisions.id, id), eq(projectRevisions.ownerUserId, scope.ownerUserId))).returning().get(),
listForClient: (scope: ClientScope, projectId: string) => db.select().from(projectRevisions).where(and(eq(projectRevisions.clientId, scope.clientId), eq(projectRevisions.projectId, projectId))).orderBy(desc(projectRevisions.createdAt)).all(),
listAllForClient: (scope: ClientScope) => db.select({ revision: projectRevisions })
.from(projectRevisions)
.innerJoin(
projects,
and(
eq(projectRevisions.projectId, projects.id),
eq(projects.clientId, scope.clientId),
),
)
.innerJoin(
clients,
and(
eq(projects.clientId, clients.id),
eq(clients.authUserId, scope.authUserId),
),
)
.where(eq(projectRevisions.clientId, scope.clientId))
.orderBy(desc(projectRevisions.createdAt))
.all()
.map(({ revision }) => revision),
},
chat: {
listSessions: (scope: OwnerScope) => db.select().from(chatSessions).where(eq(chatSessions.ownerUserId, scope.ownerUserId)).orderBy(desc(chatSessions.updatedAt)).all(),
@@ -131,6 +185,27 @@ export function createDomainRepositories(db: DomainDatabase) {
}).from(financeTransactions).where(eq(financeTransactions.ownerUserId, scope.ownerUserId)).get(),
projectStatusCounts: (scope: OwnerScope) => db.select({ status: projects.status, value: count() }).from(projects).where(eq(projects.ownerUserId, scope.ownerUserId)).groupBy(projects.status).all(),
taskStatusCounts: (scope: OwnerScope) => db.select({ status: tasks.status, value: count() }).from(tasks).where(eq(tasks.ownerUserId, scope.ownerUserId)).groupBy(tasks.status).all(),
taskStatusCountsInRange: (scope: OwnerScope, startDate: Date, endDate: Date) =>
db.select({ status: tasks.status, value: count() }).from(tasks).where(and(eq(tasks.ownerUserId, scope.ownerUserId), gte(tasks.updatedAt, startDate), lte(tasks.updatedAt, endDate))).groupBy(tasks.status).all(),
projectIncomeInRange: (scope: OwnerScope, startDate: string, endDate: string) =>
db.select({
projectId: projects.id,
name: projects.name,
amountMinor: sql<number>`coalesce(sum(${financeTransactions.amountMinor}), 0)`,
})
.from(financeTransactions)
.innerJoin(projects, eq(financeTransactions.projectId, projects.id))
.where(and(
eq(financeTransactions.ownerUserId, scope.ownerUserId),
eq(projects.ownerUserId, scope.ownerUserId),
eq(financeTransactions.type, "income"),
eq(financeTransactions.paymentStatus, "paid"),
gte(financeTransactions.transactionDate, startDate),
lte(financeTransactions.transactionDate, endDate),
))
.groupBy(projects.id, projects.name)
.orderBy(desc(sql`sum(${financeTransactions.amountMinor})`))
.all(),
},
};
}
+56
View File
@@ -0,0 +1,56 @@
import "server-only";
import { z } from "zod";
export const dashboardRangeSchema = z.enum([
"today",
"this_week",
"this_month",
"this_year",
]);
export type DashboardRange = z.infer<typeof dashboardRangeSchema>;
export function parseDashboardRange(
value: string | string[] | undefined,
fallback: DashboardRange = "this_month",
): DashboardRange {
const parsed = dashboardRangeSchema.safeParse(value);
return parsed.success ? parsed.data : fallback;
}
export function resolveDashboardRange(range: DashboardRange, now = new Date()) {
let startAt: Date;
let endAt: Date;
if (range === "today") {
startAt = new Date(now.getFullYear(), now.getMonth(), now.getDate());
endAt = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
} else if (range === "this_week") {
const mondayOffset = now.getDay() === 0 ? -6 : 1 - now.getDay();
startAt = new Date(now.getFullYear(), now.getMonth(), now.getDate() + mondayOffset);
endAt = new Date(startAt);
endAt.setDate(endAt.getDate() + 6);
endAt.setHours(23, 59, 59, 999);
} else if (range === "this_year") {
startAt = new Date(now.getFullYear(), 0, 1);
endAt = new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999);
} else {
startAt = new Date(now.getFullYear(), now.getMonth(), 1);
endAt = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999);
}
return {
startAt,
endAt,
startDate: toBusinessDate(startAt),
endDate: toBusinessDate(endAt),
};
}
function toBusinessDate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
+145 -3
View File
@@ -82,6 +82,16 @@ export class DomainService {
return this.repositories.clients.createActivity(scope, { ...value, id: value.id ?? this.id() });
}
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));
@@ -108,7 +118,15 @@ export class DomainService {
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");
const updated = this.repositories.projects.update(scope, projectId, value) ?? this.throwNotFound("Proje");
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) {
@@ -118,6 +136,7 @@ export class DomainService {
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);
}
@@ -216,6 +235,16 @@ export class DomainService {
return this.repositories.journal.list(requireOwnerScope(actor));
}
updateJournalEntry(actor: DomainActor, entryId: string, input: unknown) {
const scope = requireOwnerScope(actor);
const value = parseDomainInput(journalEntrySchema.omit({ id: true }), 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.");
}
return this.repositories.journal.update(scope, entryId, value) ?? this.throwNotFound("Günlük kaydı");
}
deleteJournalEntry(actor: DomainActor, entryId: string) {
return this.repositories.journal.remove(requireOwnerScope(actor), entryId) ?? this.throwNotFound("Günlük kaydı");
}
@@ -251,6 +280,7 @@ export class DomainService {
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();
@@ -273,9 +303,16 @@ export class DomainService {
}, { behavior: "immediate" });
}
updateRevisionStatus(actor: DomainActor, revisionId: string, statusInput: unknown) {
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(requireOwnerScope(actor), revisionId, status) ?? this.throwNotFound("Revizyon");
return this.repositories.revisions.updateStatus(scope, revisionId, status) ?? this.throwNotFound("Revizyon");
}
listRevisions(actor: DomainActor, projectId: string) {
@@ -289,6 +326,29 @@ export class DomainService {
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);
@@ -318,6 +378,88 @@ export class DomainService {
};
}
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 value = parseDomainInput(proposalCreateSchema, input);
+132
View File
@@ -0,0 +1,132 @@
import "server-only";
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { getServerConfig } from "../config";
import { getSqliteConnection } from "../db/client";
import { userAiSettings, type AiProvider } from "../db/schema/settings";
import { requireOwnerScope, type DomainActor } from "../domain/actor";
import { DomainError } from "../domain/errors";
const inputSchema = z.object({
provider: z.enum(["gemini", "openai", "groq", "ollama"]),
apiKey: z.string().trim().max(4_096).optional(),
});
export type PublicAiSettings = {
provider: AiProvider;
hasApiKey: boolean;
};
export function getPublicAiSettings(actor: DomainActor): PublicAiSettings {
const scope = requireOwnerScope(actor);
const row = getSqliteConnection().db
.select()
.from(userAiSettings)
.where(eq(userAiSettings.ownerUserId, scope.ownerUserId))
.get();
return {
provider: row?.provider ?? "gemini",
hasApiKey: Boolean(row?.encryptedApiKey),
};
}
export function updateAiSettings(actor: DomainActor, input: unknown): PublicAiSettings {
const scope = requireOwnerScope(actor);
const parsed = inputSchema.safeParse(input);
if (!parsed.success) {
throw new DomainError("VALIDATION_ERROR", "Yapay zeka ayarları geçersiz.");
}
const { db } = getSqliteConnection();
const current = db
.select()
.from(userAiSettings)
.where(eq(userAiSettings.ownerUserId, scope.ownerUserId))
.get();
const encryptedApiKey = parsed.data.provider === "ollama"
? null
: parsed.data.apiKey
? encryptSecret(parsed.data.apiKey)
: current?.encryptedApiKey ?? null;
db.insert(userAiSettings)
.values({
ownerUserId: scope.ownerUserId,
provider: parsed.data.provider,
model: null,
encryptedApiKey,
})
.onConflictDoUpdate({
target: userAiSettings.ownerUserId,
set: {
provider: parsed.data.provider,
model: null,
encryptedApiKey,
updatedAt: sqlNow(),
},
})
.run();
return { provider: parsed.data.provider, hasApiKey: Boolean(encryptedApiKey) };
}
export function getAiRuntimeSettings(actor: DomainActor): {
provider: AiProvider;
model: string | null;
apiKey: string | null;
} {
const scope = requireOwnerScope(actor);
const row = getSqliteConnection().db
.select()
.from(userAiSettings)
.where(eq(userAiSettings.ownerUserId, scope.ownerUserId))
.get();
return {
provider: row?.provider ?? "gemini",
model: row?.model ?? null,
apiKey: row?.encryptedApiKey ? decryptSecret(row.encryptedApiKey) : null,
};
}
function encryptionKey(): Buffer {
const secret = getServerConfig().betterAuthSecret
?? "neta-development-only-ai-settings-secret";
return createHash("sha256").update(`neta:ai-settings:${secret}`).digest();
}
function encryptSecret(value: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", encryptionKey(), iv);
const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return `v1.${iv.toString("base64url")}.${tag.toString("base64url")}.${ciphertext.toString("base64url")}`;
}
function decryptSecret(value: string): string {
const [version, ivValue, tagValue, ciphertextValue] = value.split(".");
if (version !== "v1" || !ivValue || !tagValue || !ciphertextValue) {
throw new DomainError("INVARIANT_VIOLATION", "AI secret formatı geçersiz.");
}
try {
const decipher = createDecipheriv(
"aes-256-gcm",
encryptionKey(),
Buffer.from(ivValue, "base64url"),
);
decipher.setAuthTag(Buffer.from(tagValue, "base64url"));
return Buffer.concat([
decipher.update(Buffer.from(ciphertextValue, "base64url")),
decipher.final(),
]).toString("utf8");
} catch {
throw new DomainError("INVARIANT_VIOLATION", "AI secret çözülemedi.");
}
}
function sqlNow(): string {
return new Date().toISOString();
}
+45
View File
@@ -0,0 +1,45 @@
import "server-only";
import { DomainError } from "../domain/errors";
export function cleanText(value: FormDataEntryValue | null): string | null {
const text = typeof value === "string" ? value.trim() : "";
return text && text !== "__none" ? text : null;
}
export function requiredText(
value: FormDataEntryValue | null,
message: string,
): string {
const text = cleanText(value);
if (!text) throw new DomainError("VALIDATION_ERROR", message);
return text;
}
export function optionalDate(value: FormDataEntryValue | null): Date | null {
const text = cleanText(value);
if (!text) return null;
const date = new Date(text);
if (Number.isNaN(date.getTime())) {
throw new DomainError("VALIDATION_ERROR", "Geçerli bir tarih girilmelidir.");
}
return date;
}
export function decimalToMinor(value: FormDataEntryValue | null): number | null {
const normalized = typeof value === "string" ? value.trim().replace(",", ".") : "";
if (!normalized) return null;
const amount = Number(normalized);
if (!Number.isFinite(amount) || amount < 0) {
throw new DomainError("VALIDATION_ERROR", "Tutar sıfır veya daha büyük olmalıdır.");
}
return Math.round((amount + Number.EPSILON) * 100);
}
export function minorToDecimal(value: number | null | undefined): number | null {
return value == null ? null : value / 100;
}
export function dateToIso(value: Date | null | undefined): string | null {
return value ? value.toISOString() : null;
}
+15
View File
@@ -0,0 +1,15 @@
import "server-only";
import { domainActorFromSession } from "../auth/domain-actor";
import { requireFreelancer } from "../auth/session";
import { getDomainService } from "../services/runtime";
export async function requireFreelancerBackend() {
const context = await requireFreelancer();
return {
context,
actor: domainActorFromSession(context),
service: getDomainService(),
};
}
+15
View File
@@ -0,0 +1,15 @@
import "server-only";
import { domainActorFromSession } from "../auth/domain-actor";
import { requireClientUser } from "../auth/session";
import { getDomainService } from "../services/runtime";
export async function requirePortalBackend() {
const context = await requireClientUser();
return {
context,
actor: domainActorFromSession(context),
service: getDomainService(),
};
}