feat(domain): complete phase 2 backend core
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import type { UserRole } from "../auth/types";
|
||||
import { DomainError } from "./errors";
|
||||
|
||||
export type DomainActor = {
|
||||
authUserId: string;
|
||||
role: UserRole;
|
||||
clientId: string | null;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export type OwnerScope = {
|
||||
kind: "owner";
|
||||
ownerUserId: string;
|
||||
};
|
||||
|
||||
export type ClientScope = {
|
||||
kind: "client";
|
||||
authUserId: string;
|
||||
clientId: string;
|
||||
};
|
||||
|
||||
export function requireOwnerScope(actor: DomainActor): OwnerScope {
|
||||
assertEnabledActor(actor);
|
||||
|
||||
if (actor.role !== "freelancer") {
|
||||
throw new DomainError("FORBIDDEN", "Bu işlem yalnızca instance sahibi tarafından yapılabilir.");
|
||||
}
|
||||
|
||||
return { kind: "owner", ownerUserId: actor.authUserId };
|
||||
}
|
||||
|
||||
export function requireClientScope(actor: DomainActor): ClientScope {
|
||||
assertEnabledActor(actor);
|
||||
|
||||
if (actor.role !== "client" || !actor.clientId) {
|
||||
throw new DomainError("FORBIDDEN", "Geçerli bir müşteri portal hesabı gerekli.");
|
||||
}
|
||||
|
||||
return { kind: "client", authUserId: actor.authUserId, clientId: actor.clientId };
|
||||
}
|
||||
|
||||
export function assertEnabledActor(actor: DomainActor): void {
|
||||
if (actor.disabled) {
|
||||
throw new DomainError("FORBIDDEN", "Kullanıcı hesabı devre dışı.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
|
||||
import * as schema from "../db/schema";
|
||||
|
||||
export type DomainDatabase = BetterSQLite3Database<typeof schema>;
|
||||
@@ -0,0 +1,38 @@
|
||||
export type DomainErrorCode =
|
||||
| "VALIDATION_ERROR"
|
||||
| "UNAUTHENTICATED"
|
||||
| "FORBIDDEN"
|
||||
| "NOT_FOUND"
|
||||
| "CONFLICT"
|
||||
| "INVARIANT_VIOLATION";
|
||||
|
||||
const statusByCode: Record<DomainErrorCode, number> = {
|
||||
VALIDATION_ERROR: 400,
|
||||
UNAUTHENTICATED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
CONFLICT: 409,
|
||||
INVARIANT_VIOLATION: 422,
|
||||
};
|
||||
|
||||
export class DomainError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(
|
||||
public readonly code: DomainErrorCode,
|
||||
message: string,
|
||||
public readonly details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "DomainError";
|
||||
this.status = statusByCode[code];
|
||||
}
|
||||
}
|
||||
|
||||
export function notFound(resource = "Kaynak"): DomainError {
|
||||
return new DomainError("NOT_FOUND", `${resource} bulunamadı.`);
|
||||
}
|
||||
|
||||
export function conflict(message: string): DomainError {
|
||||
return new DomainError("CONFLICT", message);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export type IdGenerator = () => string;
|
||||
|
||||
export const generateId: IdGenerator = randomUUID;
|
||||
@@ -0,0 +1,50 @@
|
||||
export const clientStatuses = ["active", "paused", "archived"] as const;
|
||||
export const clientPipelineStages = ["lead", "contacted", "proposal_sent", "won", "lost"] as const;
|
||||
export const clientActivityTypes = ["note", "call", "meeting", "email"] as const;
|
||||
export const projectTypes = ["client_project", "side_project"] as const;
|
||||
export const projectStatuses = ["planning", "active", "paused", "completed", "cancelled"] as const;
|
||||
export const projectProgressTypes = ["manual", "auto"] as const;
|
||||
export const taskStatuses = ["todo", "in_progress", "done", "cancelled"] as const;
|
||||
export const taskPriorities = ["low", "medium", "high", "urgent"] as const;
|
||||
export const calendarEventTypes = ["meeting", "focus", "deadline", "personal", "finance"] as const;
|
||||
export const financeTransactionTypes = ["income", "expense"] as const;
|
||||
export const paymentStatuses = ["planned", "pending", "paid", "cancelled"] as const;
|
||||
export const planningSectionCategories = [
|
||||
"overview",
|
||||
"problem",
|
||||
"goal",
|
||||
"audience",
|
||||
"scope",
|
||||
"design_system",
|
||||
"color_palette",
|
||||
"typography",
|
||||
"assets",
|
||||
"notes",
|
||||
] as const;
|
||||
export const revisionStatuses = ["pending", "in_progress", "completed", "rejected"] as const;
|
||||
export const chatMessageRoles = ["system", "user", "assistant", "tool"] as const;
|
||||
export const proposalStatuses = ["draft", "sent", "accepted", "rejected"] as const;
|
||||
export const contractStatuses = ["draft", "active", "completed", "cancelled"] as const;
|
||||
export const invoiceStatuses = ["draft", "sent", "paid", "overdue", "cancelled"] as const;
|
||||
export const subscriptionBillingCycles = ["weekly", "monthly", "yearly"] as const;
|
||||
export const subscriptionStatuses = ["active", "cancelled"] as const;
|
||||
|
||||
export type ClientStatus = (typeof clientStatuses)[number];
|
||||
export type ClientPipelineStage = (typeof clientPipelineStages)[number];
|
||||
export type ClientActivityType = (typeof clientActivityTypes)[number];
|
||||
export type ProjectType = (typeof projectTypes)[number];
|
||||
export type ProjectStatus = (typeof projectStatuses)[number];
|
||||
export type ProjectProgressType = (typeof projectProgressTypes)[number];
|
||||
export type TaskStatus = (typeof taskStatuses)[number];
|
||||
export type TaskPriority = (typeof taskPriorities)[number];
|
||||
export type CalendarEventType = (typeof calendarEventTypes)[number];
|
||||
export type FinanceTransactionType = (typeof financeTransactionTypes)[number];
|
||||
export type PaymentStatus = (typeof paymentStatuses)[number];
|
||||
export type PlanningSectionCategory = (typeof planningSectionCategories)[number];
|
||||
export type RevisionStatus = (typeof revisionStatuses)[number];
|
||||
export type ChatMessageRole = (typeof chatMessageRoles)[number];
|
||||
export type ProposalStatus = (typeof proposalStatuses)[number];
|
||||
export type ContractStatus = (typeof contractStatuses)[number];
|
||||
export type InvoiceStatus = (typeof invoiceStatuses)[number];
|
||||
export type SubscriptionBillingCycle = (typeof subscriptionBillingCycles)[number];
|
||||
export type SubscriptionStatus = (typeof subscriptionStatuses)[number];
|
||||
@@ -0,0 +1,230 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
calendarEventTypes,
|
||||
chatMessageRoles,
|
||||
clientActivityTypes,
|
||||
clientPipelineStages,
|
||||
clientStatuses,
|
||||
contractStatuses,
|
||||
financeTransactionTypes,
|
||||
invoiceStatuses,
|
||||
paymentStatuses,
|
||||
planningSectionCategories,
|
||||
projectProgressTypes,
|
||||
projectStatuses,
|
||||
projectTypes,
|
||||
proposalStatuses,
|
||||
revisionStatuses,
|
||||
subscriptionBillingCycles,
|
||||
subscriptionStatuses,
|
||||
taskPriorities,
|
||||
taskStatuses,
|
||||
} from "./types";
|
||||
import { DomainError } from "./errors";
|
||||
|
||||
export const resourceIdSchema = z.string().trim().min(1).max(128);
|
||||
export const businessDateSchema = z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Tarih YYYY-MM-DD formatında olmalıdır.");
|
||||
export const currencySchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.length(3)
|
||||
.transform((value) => value.toUpperCase());
|
||||
export const minorAmountSchema = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
|
||||
|
||||
const optionalText = (max: number) => z.string().trim().max(max).nullable().optional();
|
||||
const optionalId = resourceIdSchema.nullable().optional();
|
||||
const optionalDate = businessDateSchema.nullable().optional();
|
||||
|
||||
export const clientCreateSchema = z.object({
|
||||
id: resourceIdSchema.optional(),
|
||||
name: z.string().trim().min(1).max(160),
|
||||
companyName: optionalText(160),
|
||||
email: z.email().nullable().optional(),
|
||||
phone: optionalText(40),
|
||||
website: z.url().nullable().optional(),
|
||||
status: z.enum(clientStatuses).default("active"),
|
||||
pipelineStage: z.enum(clientPipelineStages).default("lead"),
|
||||
nextFollowUpDate: optionalDate,
|
||||
notes: optionalText(10_000),
|
||||
});
|
||||
export const clientUpdateSchema = clientCreateSchema.omit({ id: true }).partial();
|
||||
|
||||
export const clientActivityCreateSchema = z.object({
|
||||
id: resourceIdSchema.optional(),
|
||||
clientId: resourceIdSchema,
|
||||
type: z.enum(clientActivityTypes),
|
||||
title: z.string().trim().min(1).max(200),
|
||||
content: optionalText(10_000),
|
||||
activityDate: z.date().default(() => new Date()),
|
||||
});
|
||||
|
||||
export const projectCreateSchema = z.object({
|
||||
id: resourceIdSchema.optional(),
|
||||
clientId: optionalId,
|
||||
name: z.string().trim().min(1).max(200),
|
||||
type: z.enum(projectTypes).default("client_project"),
|
||||
description: optionalText(20_000),
|
||||
status: z.enum(projectStatuses).default("planning"),
|
||||
startDate: optionalDate,
|
||||
dueDate: optionalDate,
|
||||
budgetAmountMinor: minorAmountSchema.nullable().optional(),
|
||||
currency: currencySchema.default("USD"),
|
||||
progress: z.number().int().min(0).max(100).default(0),
|
||||
progressType: z.enum(projectProgressTypes).default("manual"),
|
||||
revisionQuota: z.number().int().min(0).max(10_000).default(0),
|
||||
legacyCoverImagePath: optionalText(1_000),
|
||||
coverImageAlt: optionalText(500),
|
||||
});
|
||||
export const projectUpdateSchema = projectCreateSchema.omit({ id: true }).partial();
|
||||
|
||||
export const taskCreateSchema = z.object({
|
||||
id: resourceIdSchema.optional(),
|
||||
clientId: optionalId,
|
||||
projectId: optionalId,
|
||||
sourceJournalEntryId: optionalId,
|
||||
title: z.string().trim().min(1).max(300),
|
||||
description: optionalText(20_000),
|
||||
status: z.enum(taskStatuses).default("todo"),
|
||||
priority: z.enum(taskPriorities).default("medium"),
|
||||
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().default(false),
|
||||
isPublicToClient: z.boolean().default(false),
|
||||
});
|
||||
export const taskUpdateSchema = taskCreateSchema.omit({ id: true }).partial();
|
||||
|
||||
const calendarEventBaseSchema = z.object({
|
||||
id: resourceIdSchema.optional(),
|
||||
clientId: optionalId,
|
||||
projectId: optionalId,
|
||||
taskId: optionalId,
|
||||
title: z.string().trim().min(1).max(300),
|
||||
description: optionalText(20_000),
|
||||
type: z.enum(calendarEventTypes).default("focus"),
|
||||
startsAt: z.date(),
|
||||
endsAt: z.date().nullable().optional(),
|
||||
});
|
||||
export const calendarEventCreateSchema = calendarEventBaseSchema
|
||||
.refine((value) => !value.endsAt || value.endsAt >= value.startsAt, {
|
||||
message: "Bitiş zamanı başlangıç zamanından önce olamaz.",
|
||||
path: ["endsAt"],
|
||||
});
|
||||
export const calendarEventUpdateSchema = calendarEventBaseSchema.omit({ id: true }).partial();
|
||||
|
||||
export const financeTransactionCreateSchema = z.object({
|
||||
id: resourceIdSchema.optional(),
|
||||
clientId: optionalId,
|
||||
projectId: optionalId,
|
||||
type: z.enum(financeTransactionTypes),
|
||||
amountMinor: minorAmountSchema,
|
||||
currency: currencySchema.default("USD"),
|
||||
transactionDate: businessDateSchema,
|
||||
category: optionalText(160),
|
||||
paymentStatus: z.enum(paymentStatuses).default("planned"),
|
||||
description: optionalText(10_000),
|
||||
});
|
||||
export const financeTransactionUpdateSchema = financeTransactionCreateSchema.omit({ id: true }).partial();
|
||||
|
||||
export const journalEntrySchema = z.object({
|
||||
id: resourceIdSchema.optional(),
|
||||
entryDate: businessDateSchema,
|
||||
moodScore: z.number().int().min(1).max(5).nullable().optional(),
|
||||
energyScore: z.number().int().min(1).max(5).nullable().optional(),
|
||||
workSatisfactionScore: z.number().int().min(1).max(5).nullable().optional(),
|
||||
moodLabel: optionalText(80),
|
||||
note: optionalText(30_000),
|
||||
legacyAiMetadata: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
});
|
||||
|
||||
export const planningSectionCreateSchema = z.object({
|
||||
id: resourceIdSchema.optional(),
|
||||
projectId: resourceIdSchema,
|
||||
category: z.enum(planningSectionCategories),
|
||||
title: z.string().trim().min(1).max(300),
|
||||
content: optionalText(50_000),
|
||||
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 revisionCreateSchema = z.object({
|
||||
id: resourceIdSchema.optional(),
|
||||
projectId: resourceIdSchema,
|
||||
description: z.string().trim().min(1).max(20_000),
|
||||
});
|
||||
export const revisionStatusSchema = z.enum(revisionStatuses);
|
||||
|
||||
export const chatSessionCreateSchema = z.object({
|
||||
id: resourceIdSchema.optional(),
|
||||
title: z.string().trim().min(1).max(300),
|
||||
});
|
||||
export const chatMessageCreateSchema = z.object({
|
||||
id: resourceIdSchema.optional(),
|
||||
sessionId: resourceIdSchema,
|
||||
role: z.enum(chatMessageRoles),
|
||||
content: z.string().trim().min(1).max(100_000),
|
||||
contextJournalEntryIds: z.array(resourceIdSchema).max(100).default([]),
|
||||
});
|
||||
|
||||
export const proposalCreateSchema = z.object({
|
||||
id: resourceIdSchema.optional(),
|
||||
clientId: optionalId,
|
||||
projectId: optionalId,
|
||||
title: z.string().trim().min(1).max(300),
|
||||
description: optionalText(30_000),
|
||||
amountMinor: minorAmountSchema.default(0),
|
||||
currency: currencySchema.default("TRY"),
|
||||
status: z.enum(proposalStatuses).default("draft"),
|
||||
validUntil: z.date().nullable().optional(),
|
||||
});
|
||||
export const contractCreateSchema = z.object({
|
||||
id: resourceIdSchema.optional(),
|
||||
proposalId: optionalId,
|
||||
clientId: optionalId,
|
||||
title: z.string().trim().min(1).max(300),
|
||||
content: optionalText(100_000),
|
||||
status: z.enum(contractStatuses).default("draft"),
|
||||
signedAt: z.date().nullable().optional(),
|
||||
});
|
||||
export const invoiceCreateSchema = z.object({
|
||||
id: resourceIdSchema.optional(),
|
||||
clientId: optionalId,
|
||||
projectId: optionalId,
|
||||
invoiceNumber: z.string().trim().min(1).max(100),
|
||||
amountMinor: minorAmountSchema.default(0),
|
||||
taxBasisPoints: z.number().int().min(0).max(10_000).default(0),
|
||||
currency: currencySchema.default("TRY"),
|
||||
status: z.enum(invoiceStatuses).default("draft"),
|
||||
issueDate: businessDateSchema,
|
||||
dueDate: optionalDate,
|
||||
paidAt: z.date().nullable().optional(),
|
||||
});
|
||||
export const subscriptionCreateSchema = z.object({
|
||||
id: resourceIdSchema.optional(),
|
||||
name: z.string().trim().min(1).max(300),
|
||||
amountMinor: minorAmountSchema.default(0),
|
||||
currency: currencySchema.default("TRY"),
|
||||
billingCycle: z.enum(subscriptionBillingCycles).default("monthly"),
|
||||
nextBillingDate: optionalDate,
|
||||
status: z.enum(subscriptionStatuses).default("active"),
|
||||
category: optionalText(160),
|
||||
});
|
||||
|
||||
export function parseDomainInput<TSchema extends z.ZodType>(
|
||||
schema: TSchema,
|
||||
input: unknown,
|
||||
): z.output<TSchema> {
|
||||
const result = schema.safeParse(input);
|
||||
|
||||
if (!result.success) {
|
||||
throw new DomainError("VALIDATION_ERROR", "Girilen bilgiler geçersiz.", {
|
||||
fields: result.error.flatten().fieldErrors,
|
||||
});
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
Reference in New Issue
Block a user