feat(backend): migrate freelancer and portal runtimes
This commit is contained in:
@@ -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
@@ -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,4 +1,5 @@
|
||||
export * from "./auth";
|
||||
export * from "./domain";
|
||||
export * from "./runtime";
|
||||
export * from "./settings";
|
||||
export * from "./storage";
|
||||
|
||||
@@ -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')`,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user