feat(domain): complete phase 2 backend core
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import "server-only";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { DomainError } from "../domain/errors";
|
||||
|
||||
export function apiSuccess<T>(data: T, init?: ResponseInit): NextResponse {
|
||||
return NextResponse.json({ ok: true, data }, init);
|
||||
}
|
||||
|
||||
export function apiError(error: unknown): NextResponse {
|
||||
if (error instanceof DomainError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
...(error.details ? { details: error.details } : {}),
|
||||
},
|
||||
},
|
||||
{ status: error.status },
|
||||
);
|
||||
}
|
||||
|
||||
console.error("Unhandled API error", error);
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: { code: "INTERNAL_ERROR", message: "Beklenmeyen bir sunucu hatası oluştu." } },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import "server-only";
|
||||
|
||||
import type { DomainActor } from "../domain/actor";
|
||||
import type { SessionContext } from "./session";
|
||||
|
||||
export function domainActorFromSession(context: SessionContext): DomainActor {
|
||||
return {
|
||||
authUserId: context.user.id,
|
||||
role: context.profile.role,
|
||||
clientId: context.profile.clientId,
|
||||
disabled: context.profile.disabled,
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import "server-only";
|
||||
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { hashPassword } from "better-auth/crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import type { SessionContext } from "@/server/auth/session";
|
||||
import { getServerConfig } from "@/server/config";
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
account,
|
||||
appProfiles,
|
||||
authAuditEvents,
|
||||
clients,
|
||||
portalInvitations,
|
||||
session,
|
||||
user,
|
||||
@@ -37,6 +38,7 @@ export type PortalInvitationErrorCode =
|
||||
| "INVITATION_NOT_FOUND"
|
||||
| "INVITATION_NOT_PENDING"
|
||||
| "INVITATION_EXPIRED"
|
||||
| "CLIENT_NOT_FOUND"
|
||||
| "CLIENT_ALREADY_LINKED"
|
||||
| "EMAIL_ALREADY_REGISTERED";
|
||||
|
||||
@@ -70,6 +72,25 @@ export async function createPortalInvitation(
|
||||
const { db } = getSqliteConnection();
|
||||
|
||||
const invitationId = db.transaction((tx) => {
|
||||
const client = tx
|
||||
.select({ id: clients.id, authUserId: clients.authUserId })
|
||||
.from(clients)
|
||||
.where(
|
||||
and(eq(clients.id, parsed.clientId), eq(clients.ownerUserId, actor.user.id)),
|
||||
)
|
||||
.get();
|
||||
|
||||
if (!client) {
|
||||
throw new PortalInvitationError("CLIENT_NOT_FOUND", "Müşteri bulunamadı.");
|
||||
}
|
||||
|
||||
if (client.authUserId) {
|
||||
throw new PortalInvitationError(
|
||||
"CLIENT_ALREADY_LINKED",
|
||||
"Bu müşteri için portal hesabı zaten mevcut.",
|
||||
);
|
||||
}
|
||||
|
||||
const [linkedProfile] = tx
|
||||
.select({ id: appProfiles.id })
|
||||
.from(appProfiles)
|
||||
@@ -333,6 +354,24 @@ export async function acceptPortalInvitation(input: {
|
||||
})
|
||||
.run();
|
||||
|
||||
const linkedClient = tx
|
||||
.update(clients)
|
||||
.set({ authUserId, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(clients.id, invitation.clientId),
|
||||
isNull(clients.authUserId),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
|
||||
if (linkedClient.changes !== 1) {
|
||||
throw new PortalInvitationError(
|
||||
"CLIENT_ALREADY_LINKED",
|
||||
"Müşteri kaydı bulunamadı veya başka bir hesaba bağlandı.",
|
||||
);
|
||||
}
|
||||
|
||||
const accepted = tx
|
||||
.update(portalInvitations)
|
||||
.set({ status: "accepted", acceptedAt: now })
|
||||
@@ -417,6 +456,16 @@ export function setClientPortalAccess(
|
||||
const { db } = getSqliteConnection();
|
||||
|
||||
db.transaction((tx) => {
|
||||
const ownedClient = tx
|
||||
.select({ id: clients.id })
|
||||
.from(clients)
|
||||
.where(and(eq(clients.id, clientId), eq(clients.ownerUserId, actor.user.id)))
|
||||
.get();
|
||||
|
||||
if (!ownedClient) {
|
||||
throw new PortalInvitationError("CLIENT_NOT_FOUND", "Müşteri bulunamadı.");
|
||||
}
|
||||
|
||||
const [profile] = tx
|
||||
.select({ authUserId: appProfiles.authUserId, email: appProfiles.email })
|
||||
.from(appProfiles)
|
||||
|
||||
+24
-3
@@ -1,13 +1,13 @@
|
||||
import "server-only";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import { auth } from "@/server/auth/auth";
|
||||
import type { UserRole } from "@/server/auth/types";
|
||||
import { getSqliteConnection } from "@/server/db/client";
|
||||
import { appProfiles } from "@/server/db/schema";
|
||||
import { appProfiles, clients } from "@/server/db/schema";
|
||||
|
||||
type BetterAuthSession = NonNullable<Awaited<ReturnType<typeof auth.api.getSession>>>;
|
||||
|
||||
@@ -108,5 +108,26 @@ export function getProfileByAuthUserId(authUserId: string): SessionContext["prof
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
return profile ?? null;
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (profile.role === "client") {
|
||||
if (!profile.clientId) return null;
|
||||
|
||||
const linkedClient = db
|
||||
.select({ id: clients.id })
|
||||
.from(clients)
|
||||
.where(
|
||||
and(
|
||||
eq(clients.id, profile.clientId),
|
||||
eq(clients.authUserId, profile.authUserId),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
if (!linkedClient) return null;
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
CREATE TABLE `calendar_events` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`owner_user_id` text NOT NULL,
|
||||
`client_id` text,
|
||||
`project_id` text,
|
||||
`task_id` text,
|
||||
`title` text NOT NULL,
|
||||
`description` text,
|
||||
`type` text DEFAULT 'focus' NOT NULL,
|
||||
`starts_at` integer NOT NULL,
|
||||
`ends_at` integer,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`task_id`) REFERENCES `tasks`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
CONSTRAINT "calendar_events_type_check" CHECK("calendar_events"."type" in ('meeting', 'focus', 'deadline', 'personal', 'finance')),
|
||||
CONSTRAINT "calendar_events_time_check" CHECK("calendar_events"."ends_at" is null or "calendar_events"."ends_at" >= "calendar_events"."starts_at")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `calendar_events_owner_range_idx` ON `calendar_events` (`owner_user_id`,`starts_at`);--> statement-breakpoint
|
||||
CREATE INDEX `calendar_events_project_id_idx` ON `calendar_events` (`project_id`);--> statement-breakpoint
|
||||
CREATE INDEX `calendar_events_task_id_idx` ON `calendar_events` (`task_id`);--> statement-breakpoint
|
||||
CREATE TABLE `chat_messages` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`session_id` text NOT NULL,
|
||||
`role` text NOT NULL,
|
||||
`content` text NOT NULL,
|
||||
`context_journal_entry_ids` text DEFAULT '[]' NOT NULL,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
FOREIGN KEY (`session_id`) REFERENCES `chat_sessions`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
CONSTRAINT "chat_messages_role_check" CHECK("chat_messages"."role" in ('system', 'user', 'assistant', 'tool'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `chat_messages_session_created_idx` ON `chat_messages` (`session_id`,`created_at`);--> statement-breakpoint
|
||||
CREATE TABLE `chat_sessions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`owner_user_id` text NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `chat_sessions_owner_updated_idx` ON `chat_sessions` (`owner_user_id`,`updated_at`);--> statement-breakpoint
|
||||
CREATE TABLE `client_activities` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`owner_user_id` text NOT NULL,
|
||||
`client_id` text NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`content` text,
|
||||
`activity_date` integer NOT NULL,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
CONSTRAINT "client_activities_type_check" CHECK("client_activities"."type" in ('note', 'call', 'meeting', 'email'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `client_activities_owner_client_idx` ON `client_activities` (`owner_user_id`,`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `client_activities_client_date_idx` ON `client_activities` (`client_id`,`activity_date`);--> statement-breakpoint
|
||||
CREATE TABLE `clients` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`owner_user_id` text NOT NULL,
|
||||
`auth_user_id` text,
|
||||
`name` text NOT NULL,
|
||||
`company_name` text,
|
||||
`email` text,
|
||||
`phone` text,
|
||||
`website` text,
|
||||
`status` text DEFAULT 'active' NOT NULL,
|
||||
`pipeline_stage` text DEFAULT 'lead' NOT NULL,
|
||||
`next_follow_up_date` text,
|
||||
`notes` text,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`auth_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
CONSTRAINT "clients_status_check" CHECK("clients"."status" in ('active', 'paused', 'archived')),
|
||||
CONSTRAINT "clients_pipeline_stage_check" CHECK("clients"."pipeline_stage" in ('lead', 'contacted', 'proposal_sent', 'won', 'lost'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `clients_auth_user_id_unique` ON `clients` (`auth_user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `clients_owner_user_id_idx` ON `clients` (`owner_user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `clients_owner_status_idx` ON `clients` (`owner_user_id`,`status`);--> statement-breakpoint
|
||||
CREATE INDEX `clients_owner_pipeline_idx` ON `clients` (`owner_user_id`,`pipeline_stage`);--> statement-breakpoint
|
||||
CREATE INDEX `clients_next_follow_up_date_idx` ON `clients` (`next_follow_up_date`);--> statement-breakpoint
|
||||
CREATE TABLE `contracts` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`owner_user_id` text NOT NULL,
|
||||
`proposal_id` text,
|
||||
`client_id` text,
|
||||
`title` text NOT NULL,
|
||||
`content` text,
|
||||
`status` text DEFAULT 'draft' NOT NULL,
|
||||
`signed_at` integer,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`proposal_id`) REFERENCES `proposals`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
CONSTRAINT "contracts_status_check" CHECK("contracts"."status" in ('draft', 'active', 'completed', 'cancelled'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `contracts_owner_status_idx` ON `contracts` (`owner_user_id`,`status`);--> statement-breakpoint
|
||||
CREATE TABLE `finance_transactions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`owner_user_id` text NOT NULL,
|
||||
`client_id` text,
|
||||
`project_id` text,
|
||||
`type` text NOT NULL,
|
||||
`amount_minor` integer NOT NULL,
|
||||
`currency` text DEFAULT 'USD' NOT NULL,
|
||||
`transaction_date` text NOT NULL,
|
||||
`category` text,
|
||||
`payment_status` text DEFAULT 'planned' NOT NULL,
|
||||
`description` text,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
CONSTRAINT "finance_transactions_type_check" CHECK("finance_transactions"."type" in ('income', 'expense')),
|
||||
CONSTRAINT "finance_transactions_amount_check" CHECK("finance_transactions"."amount_minor" >= 0),
|
||||
CONSTRAINT "finance_transactions_payment_status_check" CHECK("finance_transactions"."payment_status" in ('planned', 'pending', 'paid', 'cancelled')),
|
||||
CONSTRAINT "finance_transactions_currency_check" CHECK(length("finance_transactions"."currency") = 3)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `finance_transactions_owner_date_idx` ON `finance_transactions` (`owner_user_id`,`transaction_date`);--> statement-breakpoint
|
||||
CREATE INDEX `finance_transactions_owner_type_idx` ON `finance_transactions` (`owner_user_id`,`type`);--> statement-breakpoint
|
||||
CREATE INDEX `finance_transactions_client_id_idx` ON `finance_transactions` (`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `finance_transactions_project_id_idx` ON `finance_transactions` (`project_id`);--> statement-breakpoint
|
||||
CREATE TABLE `invoices` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`owner_user_id` text NOT NULL,
|
||||
`client_id` text,
|
||||
`project_id` text,
|
||||
`invoice_number` text NOT NULL,
|
||||
`amount_minor` integer DEFAULT 0 NOT NULL,
|
||||
`tax_basis_points` integer DEFAULT 0 NOT NULL,
|
||||
`currency` text DEFAULT 'TRY' NOT NULL,
|
||||
`status` text DEFAULT 'draft' NOT NULL,
|
||||
`issue_date` text NOT NULL,
|
||||
`due_date` text,
|
||||
`paid_at` integer,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
CONSTRAINT "invoices_status_check" CHECK("invoices"."status" in ('draft', 'sent', 'paid', 'overdue', 'cancelled')),
|
||||
CONSTRAINT "invoices_amount_check" CHECK("invoices"."amount_minor" >= 0),
|
||||
CONSTRAINT "invoices_tax_check" CHECK("invoices"."tax_basis_points" between 0 and 10000)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `invoices_owner_number_unique` ON `invoices` (`owner_user_id`,`invoice_number`);--> statement-breakpoint
|
||||
CREATE INDEX `invoices_owner_status_idx` ON `invoices` (`owner_user_id`,`status`);--> statement-breakpoint
|
||||
CREATE TABLE `journal_entries` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`owner_user_id` text NOT NULL,
|
||||
`entry_date` text NOT NULL,
|
||||
`mood_score` integer,
|
||||
`energy_score` integer,
|
||||
`work_satisfaction_score` integer,
|
||||
`mood_label` text,
|
||||
`note` text,
|
||||
`legacy_ai_metadata` text,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
CONSTRAINT "journal_entries_mood_score_check" CHECK("journal_entries"."mood_score" is null or "journal_entries"."mood_score" between 1 and 5),
|
||||
CONSTRAINT "journal_entries_energy_score_check" CHECK("journal_entries"."energy_score" is null or "journal_entries"."energy_score" between 1 and 5),
|
||||
CONSTRAINT "journal_entries_work_score_check" CHECK("journal_entries"."work_satisfaction_score" is null or "journal_entries"."work_satisfaction_score" between 1 and 5)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `journal_entries_owner_date_unique` ON `journal_entries` (`owner_user_id`,`entry_date`);--> statement-breakpoint
|
||||
CREATE INDEX `journal_entries_owner_date_idx` ON `journal_entries` (`owner_user_id`,`entry_date`);--> statement-breakpoint
|
||||
CREATE TABLE `project_planning_sections` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`owner_user_id` text NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`category` text NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`content` text,
|
||||
`metadata` text DEFAULT '{}' NOT NULL,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
CONSTRAINT "project_planning_sections_category_check" CHECK("project_planning_sections"."category" in ('overview', 'problem', 'goal', 'audience', 'scope', 'design_system', 'color_palette', 'typography', 'assets', 'notes'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `project_planning_sections_owner_idx` ON `project_planning_sections` (`owner_user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `project_planning_sections_project_order_idx` ON `project_planning_sections` (`project_id`,`sort_order`);--> statement-breakpoint
|
||||
CREATE TABLE `project_revisions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`owner_user_id` text NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`client_id` text NOT NULL,
|
||||
`requested_by_user_id` text NOT NULL,
|
||||
`description` text NOT NULL,
|
||||
`status` text DEFAULT 'pending' NOT NULL,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`requested_by_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
CONSTRAINT "project_revisions_status_check" CHECK("project_revisions"."status" in ('pending', 'in_progress', 'completed', 'rejected'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `project_revisions_owner_project_idx` ON `project_revisions` (`owner_user_id`,`project_id`);--> statement-breakpoint
|
||||
CREATE INDEX `project_revisions_client_project_idx` ON `project_revisions` (`client_id`,`project_id`);--> statement-breakpoint
|
||||
CREATE TABLE `projects` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`owner_user_id` text NOT NULL,
|
||||
`client_id` text,
|
||||
`name` text NOT NULL,
|
||||
`type` text DEFAULT 'client_project' NOT NULL,
|
||||
`description` text,
|
||||
`status` text DEFAULT 'planning' NOT NULL,
|
||||
`start_date` text,
|
||||
`due_date` text,
|
||||
`budget_amount_minor` integer,
|
||||
`currency` text DEFAULT 'USD' NOT NULL,
|
||||
`progress` integer DEFAULT 0 NOT NULL,
|
||||
`progress_type` text DEFAULT 'manual' NOT NULL,
|
||||
`revision_quota` integer DEFAULT 0 NOT NULL,
|
||||
`legacy_cover_image_path` text,
|
||||
`cover_image_alt` text,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
CONSTRAINT "projects_type_check" CHECK("projects"."type" in ('client_project', 'side_project')),
|
||||
CONSTRAINT "projects_status_check" CHECK("projects"."status" in ('planning', 'active', 'paused', 'completed', 'cancelled')),
|
||||
CONSTRAINT "projects_progress_check" CHECK("projects"."progress" between 0 and 100),
|
||||
CONSTRAINT "projects_revision_quota_check" CHECK("projects"."revision_quota" >= 0),
|
||||
CONSTRAINT "projects_budget_check" CHECK("projects"."budget_amount_minor" is null or "projects"."budget_amount_minor" >= 0),
|
||||
CONSTRAINT "projects_currency_check" CHECK(length("projects"."currency") = 3)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `projects_owner_user_id_idx` ON `projects` (`owner_user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `projects_owner_status_idx` ON `projects` (`owner_user_id`,`status`);--> statement-breakpoint
|
||||
CREATE INDEX `projects_client_id_idx` ON `projects` (`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `projects_due_date_idx` ON `projects` (`due_date`);--> statement-breakpoint
|
||||
CREATE TABLE `proposals` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`owner_user_id` text NOT NULL,
|
||||
`client_id` text,
|
||||
`project_id` text,
|
||||
`title` text NOT NULL,
|
||||
`description` text,
|
||||
`amount_minor` integer DEFAULT 0 NOT NULL,
|
||||
`currency` text DEFAULT 'TRY' NOT NULL,
|
||||
`status` text DEFAULT 'draft' NOT NULL,
|
||||
`valid_until` integer,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
CONSTRAINT "proposals_status_check" CHECK("proposals"."status" in ('draft', 'sent', 'accepted', 'rejected')),
|
||||
CONSTRAINT "proposals_amount_check" CHECK("proposals"."amount_minor" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `proposals_owner_status_idx` ON `proposals` (`owner_user_id`,`status`);--> statement-breakpoint
|
||||
CREATE TABLE `subscriptions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`owner_user_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`amount_minor` integer DEFAULT 0 NOT NULL,
|
||||
`currency` text DEFAULT 'TRY' NOT NULL,
|
||||
`billing_cycle` text DEFAULT 'monthly' NOT NULL,
|
||||
`next_billing_date` text,
|
||||
`status` text DEFAULT 'active' NOT NULL,
|
||||
`category` text,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
CONSTRAINT "subscriptions_cycle_check" CHECK("subscriptions"."billing_cycle" in ('weekly', 'monthly', 'yearly')),
|
||||
CONSTRAINT "subscriptions_status_check" CHECK("subscriptions"."status" in ('active', 'cancelled')),
|
||||
CONSTRAINT "subscriptions_amount_check" CHECK("subscriptions"."amount_minor" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `subscriptions_owner_status_idx` ON `subscriptions` (`owner_user_id`,`status`);--> statement-breakpoint
|
||||
CREATE INDEX `subscriptions_next_billing_date_idx` ON `subscriptions` (`next_billing_date`);--> statement-breakpoint
|
||||
CREATE TABLE `tasks` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`owner_user_id` text NOT NULL,
|
||||
`client_id` text,
|
||||
`project_id` text,
|
||||
`source_journal_entry_id` text,
|
||||
`title` text NOT NULL,
|
||||
`description` text,
|
||||
`status` text DEFAULT 'todo' NOT NULL,
|
||||
`priority` text DEFAULT 'medium' NOT NULL,
|
||||
`scheduled_date` text,
|
||||
`due_at` integer,
|
||||
`estimated_minutes` integer,
|
||||
`actual_minutes` integer,
|
||||
`ai_generated` integer DEFAULT false NOT NULL,
|
||||
`is_public_to_client` integer DEFAULT false NOT NULL,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`source_journal_entry_id`) REFERENCES `journal_entries`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
CONSTRAINT "tasks_status_check" CHECK("tasks"."status" in ('todo', 'in_progress', 'done', 'cancelled')),
|
||||
CONSTRAINT "tasks_priority_check" CHECK("tasks"."priority" in ('low', 'medium', 'high', 'urgent')),
|
||||
CONSTRAINT "tasks_estimated_minutes_check" CHECK("tasks"."estimated_minutes" is null or "tasks"."estimated_minutes" >= 0),
|
||||
CONSTRAINT "tasks_actual_minutes_check" CHECK("tasks"."actual_minutes" is null or "tasks"."actual_minutes" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `tasks_owner_user_id_idx` ON `tasks` (`owner_user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `tasks_owner_status_idx` ON `tasks` (`owner_user_id`,`status`);--> statement-breakpoint
|
||||
CREATE INDEX `tasks_project_id_idx` ON `tasks` (`project_id`);--> statement-breakpoint
|
||||
CREATE INDEX `tasks_client_id_idx` ON `tasks` (`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `tasks_due_at_idx` ON `tasks` (`due_at`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,13 @@
|
||||
"when": 1784205329112,
|
||||
"tag": "0002_mighty_korg",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1784208712933,
|
||||
"tag": "0003_chief_excalibur",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
PortalInvitationStatus,
|
||||
SetupStatus,
|
||||
UserRole,
|
||||
} from "@/server/auth/types";
|
||||
} from "../../auth/types";
|
||||
|
||||
const nowMs = sql`(cast(unixepoch('subsecond') * 1000 as integer))`;
|
||||
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
check,
|
||||
index,
|
||||
integer,
|
||||
sqliteTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import {
|
||||
type CalendarEventType,
|
||||
type ChatMessageRole,
|
||||
type ClientActivityType,
|
||||
type ClientPipelineStage,
|
||||
type ClientStatus,
|
||||
type ContractStatus,
|
||||
type FinanceTransactionType,
|
||||
type InvoiceStatus,
|
||||
type PaymentStatus,
|
||||
type PlanningSectionCategory,
|
||||
type ProjectProgressType,
|
||||
type ProjectStatus,
|
||||
type ProjectType,
|
||||
type ProposalStatus,
|
||||
type RevisionStatus,
|
||||
type SubscriptionBillingCycle,
|
||||
type SubscriptionStatus,
|
||||
type TaskPriority,
|
||||
type TaskStatus,
|
||||
} from "../../domain/types";
|
||||
import { user } from "./auth";
|
||||
|
||||
const nowMs = sql`(cast(unixepoch('subsecond') * 1000 as integer))`;
|
||||
|
||||
export const clients = sqliteTable(
|
||||
"clients",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
ownerUserId: text("owner_user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
authUserId: text("auth_user_id").references(() => user.id, { onDelete: "set null" }),
|
||||
name: text("name").notNull(),
|
||||
companyName: text("company_name"),
|
||||
email: text("email"),
|
||||
phone: text("phone"),
|
||||
website: text("website"),
|
||||
status: text("status").$type<ClientStatus>().default("active").notNull(),
|
||||
pipelineStage: text("pipeline_stage").$type<ClientPipelineStage>().default("lead").notNull(),
|
||||
nextFollowUpDate: text("next_follow_up_date"),
|
||||
notes: text("notes"),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.default(nowMs)
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("clients_auth_user_id_unique").on(table.authUserId),
|
||||
index("clients_owner_user_id_idx").on(table.ownerUserId),
|
||||
index("clients_owner_status_idx").on(table.ownerUserId, table.status),
|
||||
index("clients_owner_pipeline_idx").on(table.ownerUserId, table.pipelineStage),
|
||||
index("clients_next_follow_up_date_idx").on(table.nextFollowUpDate),
|
||||
check("clients_status_check", sql`${table.status} in ('active', 'paused', 'archived')`),
|
||||
check(
|
||||
"clients_pipeline_stage_check",
|
||||
sql`${table.pipelineStage} in ('lead', 'contacted', 'proposal_sent', 'won', 'lost')`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const projects = sqliteTable(
|
||||
"projects",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
ownerUserId: text("owner_user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
clientId: text("client_id").references(() => clients.id, { onDelete: "set null" }),
|
||||
name: text("name").notNull(),
|
||||
type: text("type").$type<ProjectType>().default("client_project").notNull(),
|
||||
description: text("description"),
|
||||
status: text("status").$type<ProjectStatus>().default("planning").notNull(),
|
||||
startDate: text("start_date"),
|
||||
dueDate: text("due_date"),
|
||||
budgetAmountMinor: integer("budget_amount_minor"),
|
||||
currency: text("currency").default("USD").notNull(),
|
||||
progress: integer("progress").default(0).notNull(),
|
||||
progressType: text("progress_type").$type<ProjectProgressType>().default("manual").notNull(),
|
||||
revisionQuota: integer("revision_quota").default(0).notNull(),
|
||||
legacyCoverImagePath: text("legacy_cover_image_path"),
|
||||
coverImageAlt: text("cover_image_alt"),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.default(nowMs)
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("projects_owner_user_id_idx").on(table.ownerUserId),
|
||||
index("projects_owner_status_idx").on(table.ownerUserId, table.status),
|
||||
index("projects_client_id_idx").on(table.clientId),
|
||||
index("projects_due_date_idx").on(table.dueDate),
|
||||
check("projects_type_check", sql`${table.type} in ('client_project', 'side_project')`),
|
||||
check(
|
||||
"projects_status_check",
|
||||
sql`${table.status} in ('planning', 'active', 'paused', 'completed', 'cancelled')`,
|
||||
),
|
||||
check("projects_progress_check", sql`${table.progress} between 0 and 100`),
|
||||
check("projects_revision_quota_check", sql`${table.revisionQuota} >= 0`),
|
||||
check("projects_budget_check", sql`${table.budgetAmountMinor} is null or ${table.budgetAmountMinor} >= 0`),
|
||||
check("projects_currency_check", sql`length(${table.currency}) = 3`),
|
||||
],
|
||||
);
|
||||
|
||||
export const journalEntries = sqliteTable(
|
||||
"journal_entries",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
ownerUserId: text("owner_user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
entryDate: text("entry_date").notNull(),
|
||||
moodScore: integer("mood_score"),
|
||||
energyScore: integer("energy_score"),
|
||||
workSatisfactionScore: integer("work_satisfaction_score"),
|
||||
moodLabel: text("mood_label"),
|
||||
note: text("note"),
|
||||
legacyAiMetadata: text("legacy_ai_metadata", { mode: "json" }).$type<Record<string, unknown> | null>(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.default(nowMs)
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("journal_entries_owner_date_unique").on(table.ownerUserId, table.entryDate),
|
||||
index("journal_entries_owner_date_idx").on(table.ownerUserId, table.entryDate),
|
||||
check("journal_entries_mood_score_check", sql`${table.moodScore} is null or ${table.moodScore} between 1 and 5`),
|
||||
check("journal_entries_energy_score_check", sql`${table.energyScore} is null or ${table.energyScore} between 1 and 5`),
|
||||
check(
|
||||
"journal_entries_work_score_check",
|
||||
sql`${table.workSatisfactionScore} is null or ${table.workSatisfactionScore} between 1 and 5`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const tasks = sqliteTable(
|
||||
"tasks",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
ownerUserId: text("owner_user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
clientId: text("client_id").references(() => clients.id, { onDelete: "set null" }),
|
||||
projectId: text("project_id").references(() => projects.id, { onDelete: "set null" }),
|
||||
sourceJournalEntryId: text("source_journal_entry_id").references(() => journalEntries.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
title: text("title").notNull(),
|
||||
description: text("description"),
|
||||
status: text("status").$type<TaskStatus>().default("todo").notNull(),
|
||||
priority: text("priority").$type<TaskPriority>().default("medium").notNull(),
|
||||
scheduledDate: text("scheduled_date"),
|
||||
dueAt: integer("due_at", { mode: "timestamp_ms" }),
|
||||
estimatedMinutes: integer("estimated_minutes"),
|
||||
actualMinutes: integer("actual_minutes"),
|
||||
aiGenerated: integer("ai_generated", { mode: "boolean" }).default(false).notNull(),
|
||||
isPublicToClient: integer("is_public_to_client", { mode: "boolean" }).default(false).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.default(nowMs)
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("tasks_owner_user_id_idx").on(table.ownerUserId),
|
||||
index("tasks_owner_status_idx").on(table.ownerUserId, table.status),
|
||||
index("tasks_project_id_idx").on(table.projectId),
|
||||
index("tasks_client_id_idx").on(table.clientId),
|
||||
index("tasks_due_at_idx").on(table.dueAt),
|
||||
check("tasks_status_check", sql`${table.status} in ('todo', 'in_progress', 'done', 'cancelled')`),
|
||||
check("tasks_priority_check", sql`${table.priority} in ('low', 'medium', 'high', 'urgent')`),
|
||||
check("tasks_estimated_minutes_check", sql`${table.estimatedMinutes} is null or ${table.estimatedMinutes} >= 0`),
|
||||
check("tasks_actual_minutes_check", sql`${table.actualMinutes} is null or ${table.actualMinutes} >= 0`),
|
||||
],
|
||||
);
|
||||
|
||||
export const calendarEvents = sqliteTable(
|
||||
"calendar_events",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
ownerUserId: text("owner_user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
clientId: text("client_id").references(() => clients.id, { onDelete: "set null" }),
|
||||
projectId: text("project_id").references(() => projects.id, { onDelete: "set null" }),
|
||||
taskId: text("task_id").references(() => tasks.id, { onDelete: "set null" }),
|
||||
title: text("title").notNull(),
|
||||
description: text("description"),
|
||||
type: text("type").$type<CalendarEventType>().default("focus").notNull(),
|
||||
startsAt: integer("starts_at", { mode: "timestamp_ms" }).notNull(),
|
||||
endsAt: integer("ends_at", { mode: "timestamp_ms" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.default(nowMs)
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("calendar_events_owner_range_idx").on(table.ownerUserId, table.startsAt),
|
||||
index("calendar_events_project_id_idx").on(table.projectId),
|
||||
index("calendar_events_task_id_idx").on(table.taskId),
|
||||
check("calendar_events_type_check", sql`${table.type} in ('meeting', 'focus', 'deadline', 'personal', 'finance')`),
|
||||
check("calendar_events_time_check", sql`${table.endsAt} is null or ${table.endsAt} >= ${table.startsAt}`),
|
||||
],
|
||||
);
|
||||
|
||||
export const financeTransactions = sqliteTable(
|
||||
"finance_transactions",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
ownerUserId: text("owner_user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
clientId: text("client_id").references(() => clients.id, { onDelete: "set null" }),
|
||||
projectId: text("project_id").references(() => projects.id, { onDelete: "set null" }),
|
||||
type: text("type").$type<FinanceTransactionType>().notNull(),
|
||||
amountMinor: integer("amount_minor").notNull(),
|
||||
currency: text("currency").default("USD").notNull(),
|
||||
transactionDate: text("transaction_date").notNull(),
|
||||
category: text("category"),
|
||||
paymentStatus: text("payment_status").$type<PaymentStatus>().default("planned").notNull(),
|
||||
description: text("description"),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.default(nowMs)
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("finance_transactions_owner_date_idx").on(table.ownerUserId, table.transactionDate),
|
||||
index("finance_transactions_owner_type_idx").on(table.ownerUserId, table.type),
|
||||
index("finance_transactions_client_id_idx").on(table.clientId),
|
||||
index("finance_transactions_project_id_idx").on(table.projectId),
|
||||
check("finance_transactions_type_check", sql`${table.type} in ('income', 'expense')`),
|
||||
check("finance_transactions_amount_check", sql`${table.amountMinor} >= 0`),
|
||||
check(
|
||||
"finance_transactions_payment_status_check",
|
||||
sql`${table.paymentStatus} in ('planned', 'pending', 'paid', 'cancelled')`,
|
||||
),
|
||||
check("finance_transactions_currency_check", sql`length(${table.currency}) = 3`),
|
||||
],
|
||||
);
|
||||
|
||||
export const clientActivities = sqliteTable(
|
||||
"client_activities",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
ownerUserId: text("owner_user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
clientId: text("client_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "cascade" }),
|
||||
type: text("type").$type<ClientActivityType>().notNull(),
|
||||
title: text("title").notNull(),
|
||||
content: text("content"),
|
||||
activityDate: integer("activity_date", { mode: "timestamp_ms" }).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("client_activities_owner_client_idx").on(table.ownerUserId, table.clientId),
|
||||
index("client_activities_client_date_idx").on(table.clientId, table.activityDate),
|
||||
check("client_activities_type_check", sql`${table.type} in ('note', 'call', 'meeting', 'email')`),
|
||||
],
|
||||
);
|
||||
|
||||
export const projectPlanningSections = sqliteTable(
|
||||
"project_planning_sections",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
ownerUserId: text("owner_user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
category: text("category").$type<PlanningSectionCategory>().notNull(),
|
||||
title: text("title").notNull(),
|
||||
content: text("content"),
|
||||
metadata: text("metadata", { mode: "json" }).$type<Record<string, unknown>>().default({}).notNull(),
|
||||
sortOrder: integer("sort_order").default(0).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.default(nowMs)
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("project_planning_sections_owner_idx").on(table.ownerUserId),
|
||||
index("project_planning_sections_project_order_idx").on(table.projectId, table.sortOrder),
|
||||
check(
|
||||
"project_planning_sections_category_check",
|
||||
sql`${table.category} in ('overview', 'problem', 'goal', 'audience', 'scope', 'design_system', 'color_palette', 'typography', 'assets', 'notes')`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const projectRevisions = sqliteTable(
|
||||
"project_revisions",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
ownerUserId: text("owner_user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
clientId: text("client_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "cascade" }),
|
||||
requestedByUserId: text("requested_by_user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
description: text("description").notNull(),
|
||||
status: text("status").$type<RevisionStatus>().default("pending").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.default(nowMs)
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("project_revisions_owner_project_idx").on(table.ownerUserId, table.projectId),
|
||||
index("project_revisions_client_project_idx").on(table.clientId, table.projectId),
|
||||
check(
|
||||
"project_revisions_status_check",
|
||||
sql`${table.status} in ('pending', 'in_progress', 'completed', 'rejected')`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const chatSessions = sqliteTable(
|
||||
"chat_sessions",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
ownerUserId: text("owner_user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
title: text("title").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.default(nowMs)
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("chat_sessions_owner_updated_idx").on(table.ownerUserId, table.updatedAt)],
|
||||
);
|
||||
|
||||
export const chatMessages = sqliteTable(
|
||||
"chat_messages",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
sessionId: text("session_id")
|
||||
.notNull()
|
||||
.references(() => chatSessions.id, { onDelete: "cascade" }),
|
||||
role: text("role").$type<ChatMessageRole>().notNull(),
|
||||
content: text("content").notNull(),
|
||||
contextJournalEntryIds: text("context_journal_entry_ids", { mode: "json" })
|
||||
.$type<string[]>()
|
||||
.default([])
|
||||
.notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("chat_messages_session_created_idx").on(table.sessionId, table.createdAt),
|
||||
check("chat_messages_role_check", sql`${table.role} in ('system', 'user', 'assistant', 'tool')`),
|
||||
],
|
||||
);
|
||||
|
||||
export const proposals = sqliteTable(
|
||||
"proposals",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
ownerUserId: text("owner_user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
|
||||
clientId: text("client_id").references(() => clients.id, { onDelete: "set null" }),
|
||||
projectId: text("project_id").references(() => projects.id, { onDelete: "set null" }),
|
||||
title: text("title").notNull(),
|
||||
description: text("description"),
|
||||
amountMinor: integer("amount_minor").default(0).notNull(),
|
||||
currency: text("currency").default("TRY").notNull(),
|
||||
status: text("status").$type<ProposalStatus>().default("draft").notNull(),
|
||||
validUntil: integer("valid_until", { mode: "timestamp_ms" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("proposals_owner_status_idx").on(table.ownerUserId, table.status),
|
||||
check("proposals_status_check", sql`${table.status} in ('draft', 'sent', 'accepted', 'rejected')`),
|
||||
check("proposals_amount_check", sql`${table.amountMinor} >= 0`),
|
||||
],
|
||||
);
|
||||
|
||||
export const contracts = sqliteTable(
|
||||
"contracts",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
ownerUserId: text("owner_user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
|
||||
proposalId: text("proposal_id").references(() => proposals.id, { onDelete: "set null" }),
|
||||
clientId: text("client_id").references(() => clients.id, { onDelete: "set null" }),
|
||||
title: text("title").notNull(),
|
||||
content: text("content"),
|
||||
status: text("status").$type<ContractStatus>().default("draft").notNull(),
|
||||
signedAt: integer("signed_at", { mode: "timestamp_ms" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("contracts_owner_status_idx").on(table.ownerUserId, table.status),
|
||||
check("contracts_status_check", sql`${table.status} in ('draft', 'active', 'completed', 'cancelled')`),
|
||||
],
|
||||
);
|
||||
|
||||
export const invoices = sqliteTable(
|
||||
"invoices",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
ownerUserId: text("owner_user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
|
||||
clientId: text("client_id").references(() => clients.id, { onDelete: "set null" }),
|
||||
projectId: text("project_id").references(() => projects.id, { onDelete: "set null" }),
|
||||
invoiceNumber: text("invoice_number").notNull(),
|
||||
amountMinor: integer("amount_minor").default(0).notNull(),
|
||||
taxBasisPoints: integer("tax_basis_points").default(0).notNull(),
|
||||
currency: text("currency").default("TRY").notNull(),
|
||||
status: text("status").$type<InvoiceStatus>().default("draft").notNull(),
|
||||
issueDate: text("issue_date").notNull(),
|
||||
dueDate: text("due_date"),
|
||||
paidAt: integer("paid_at", { mode: "timestamp_ms" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("invoices_owner_number_unique").on(table.ownerUserId, table.invoiceNumber),
|
||||
index("invoices_owner_status_idx").on(table.ownerUserId, table.status),
|
||||
check("invoices_status_check", sql`${table.status} in ('draft', 'sent', 'paid', 'overdue', 'cancelled')`),
|
||||
check("invoices_amount_check", sql`${table.amountMinor} >= 0`),
|
||||
check("invoices_tax_check", sql`${table.taxBasisPoints} between 0 and 10000`),
|
||||
],
|
||||
);
|
||||
|
||||
export const subscriptions = sqliteTable(
|
||||
"subscriptions",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
ownerUserId: text("owner_user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
amountMinor: integer("amount_minor").default(0).notNull(),
|
||||
currency: text("currency").default("TRY").notNull(),
|
||||
billingCycle: text("billing_cycle").$type<SubscriptionBillingCycle>().default("monthly").notNull(),
|
||||
nextBillingDate: text("next_billing_date"),
|
||||
status: text("status").$type<SubscriptionStatus>().default("active").notNull(),
|
||||
category: text("category"),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("subscriptions_owner_status_idx").on(table.ownerUserId, table.status),
|
||||
index("subscriptions_next_billing_date_idx").on(table.nextBillingDate),
|
||||
check("subscriptions_cycle_check", sql`${table.billingCycle} in ('weekly', 'monthly', 'yearly')`),
|
||||
check("subscriptions_status_check", sql`${table.status} in ('active', 'cancelled')`),
|
||||
check("subscriptions_amount_check", sql`${table.amountMinor} >= 0`),
|
||||
],
|
||||
);
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./auth";
|
||||
export * from "./domain";
|
||||
export * from "./runtime";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { and, asc, count, desc, eq, ne, sql } from "drizzle-orm";
|
||||
import {
|
||||
calendarEvents,
|
||||
chatMessages,
|
||||
chatSessions,
|
||||
clientActivities,
|
||||
clients,
|
||||
contracts,
|
||||
financeTransactions,
|
||||
invoices,
|
||||
journalEntries,
|
||||
projectPlanningSections,
|
||||
projectRevisions,
|
||||
projects,
|
||||
proposals,
|
||||
subscriptions,
|
||||
tasks,
|
||||
} from "../db/schema/domain";
|
||||
import type { ClientScope, OwnerScope } from "../domain/actor";
|
||||
import type { DomainDatabase } from "../domain/database";
|
||||
|
||||
export function createDomainRepositories(db: DomainDatabase) {
|
||||
return {
|
||||
clients: {
|
||||
list: (scope: OwnerScope) =>
|
||||
db.select().from(clients).where(eq(clients.ownerUserId, scope.ownerUserId)).orderBy(desc(clients.updatedAt)).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) =>
|
||||
db.select().from(clients).where(and(eq(clients.id, scope.clientId), eq(clients.authUserId, scope.authUserId))).get(),
|
||||
create: (scope: OwnerScope, value: Omit<typeof clients.$inferInsert, "ownerUserId">) =>
|
||||
db.insert(clients).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
|
||||
update: (scope: OwnerScope, id: string, value: Partial<typeof clients.$inferInsert>) =>
|
||||
db.update(clients).set(value).where(and(eq(clients.id, id), eq(clients.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||
remove: (scope: OwnerScope, id: string) =>
|
||||
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(),
|
||||
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(),
|
||||
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(),
|
||||
listForClient: (scope: ClientScope) =>
|
||||
db.select().from(projects).where(eq(projects.clientId, scope.clientId)).orderBy(desc(projects.updatedAt)).all(),
|
||||
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>) =>
|
||||
db.update(projects).set(value).where(and(eq(projects.id, id), eq(projects.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||
remove: (scope: OwnerScope, id: string) =>
|
||||
db.delete(projects).where(and(eq(projects.id, id), eq(projects.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||
},
|
||||
tasks: {
|
||||
list: (scope: OwnerScope) =>
|
||||
db.select().from(tasks).where(eq(tasks.ownerUserId, scope.ownerUserId)).orderBy(desc(tasks.updatedAt)).all(),
|
||||
get: (scope: OwnerScope, id: string) =>
|
||||
db.select().from(tasks).where(and(eq(tasks.id, id), eq(tasks.ownerUserId, scope.ownerUserId))).get(),
|
||||
listPublicForClient: (scope: ClientScope, projectId?: string) =>
|
||||
db.select().from(tasks).where(and(eq(tasks.clientId, scope.clientId), eq(tasks.isPublicToClient, true), projectId ? eq(tasks.projectId, projectId) : undefined)).orderBy(asc(tasks.dueAt)).all(),
|
||||
create: (scope: OwnerScope, value: Omit<typeof tasks.$inferInsert, "ownerUserId">) =>
|
||||
db.insert(tasks).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
|
||||
update: (scope: OwnerScope, id: string, value: Partial<typeof tasks.$inferInsert>) =>
|
||||
db.update(tasks).set(value).where(and(eq(tasks.id, id), eq(tasks.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||
remove: (scope: OwnerScope, id: string) =>
|
||||
db.delete(tasks).where(and(eq(tasks.id, id), eq(tasks.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||
progressCounts: (scope: OwnerScope, projectId: string) =>
|
||||
db.select({ total: count(), done: sql<number>`sum(case when ${tasks.status} = 'done' then 1 else 0 end)` }).from(tasks).where(and(eq(tasks.ownerUserId, scope.ownerUserId), eq(tasks.projectId, projectId), ne(tasks.status, "cancelled"))).get(),
|
||||
},
|
||||
planning: {
|
||||
list: (scope: OwnerScope, projectId: string) =>
|
||||
db.select().from(projectPlanningSections).where(and(eq(projectPlanningSections.ownerUserId, scope.ownerUserId), eq(projectPlanningSections.projectId, projectId))).orderBy(asc(projectPlanningSections.sortOrder)).all(),
|
||||
listForClient: (scope: ClientScope, projectId: string) =>
|
||||
db.select({ section: projectPlanningSections }).from(projectPlanningSections).innerJoin(projects, eq(projectPlanningSections.projectId, projects.id)).where(and(eq(projectPlanningSections.projectId, projectId), eq(projects.clientId, scope.clientId))).orderBy(asc(projectPlanningSections.sortOrder)).all().map(({ section }) => section),
|
||||
create: (scope: OwnerScope, value: Omit<typeof projectPlanningSections.$inferInsert, "ownerUserId">) =>
|
||||
db.insert(projectPlanningSections).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
|
||||
get: (scope: OwnerScope, id: string) => db.select().from(projectPlanningSections).where(and(eq(projectPlanningSections.id, id), eq(projectPlanningSections.ownerUserId, scope.ownerUserId))).get(),
|
||||
update: (scope: OwnerScope, id: string, value: Partial<typeof projectPlanningSections.$inferInsert>) => db.update(projectPlanningSections).set(value).where(and(eq(projectPlanningSections.id, id), eq(projectPlanningSections.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||
remove: (scope: OwnerScope, id: string) => db.delete(projectPlanningSections).where(and(eq(projectPlanningSections.id, id), eq(projectPlanningSections.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||
},
|
||||
calendar: {
|
||||
list: (scope: OwnerScope) => db.select().from(calendarEvents).where(eq(calendarEvents.ownerUserId, scope.ownerUserId)).orderBy(asc(calendarEvents.startsAt)).all(),
|
||||
get: (scope: OwnerScope, id: string) => db.select().from(calendarEvents).where(and(eq(calendarEvents.id, id), eq(calendarEvents.ownerUserId, scope.ownerUserId))).get(),
|
||||
create: (scope: OwnerScope, value: Omit<typeof calendarEvents.$inferInsert, "ownerUserId">) => db.insert(calendarEvents).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
|
||||
update: (scope: OwnerScope, id: string, value: Partial<typeof calendarEvents.$inferInsert>) => db.update(calendarEvents).set(value).where(and(eq(calendarEvents.id, id), eq(calendarEvents.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||
remove: (scope: OwnerScope, id: string) => db.delete(calendarEvents).where(and(eq(calendarEvents.id, id), eq(calendarEvents.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||
},
|
||||
finance: {
|
||||
list: (scope: OwnerScope) => db.select().from(financeTransactions).where(eq(financeTransactions.ownerUserId, scope.ownerUserId)).orderBy(desc(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(),
|
||||
remove: (scope: OwnerScope, id: string) => db.delete(financeTransactions).where(and(eq(financeTransactions.id, id), eq(financeTransactions.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||
},
|
||||
journal: {
|
||||
list: (scope: OwnerScope) => db.select().from(journalEntries).where(eq(journalEntries.ownerUserId, scope.ownerUserId)).orderBy(desc(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(),
|
||||
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(),
|
||||
},
|
||||
chat: {
|
||||
listSessions: (scope: OwnerScope) => db.select().from(chatSessions).where(eq(chatSessions.ownerUserId, scope.ownerUserId)).orderBy(desc(chatSessions.updatedAt)).all(),
|
||||
getSession: (scope: OwnerScope, id: string) => db.select().from(chatSessions).where(and(eq(chatSessions.id, id), eq(chatSessions.ownerUserId, scope.ownerUserId))).get(),
|
||||
createSession: (scope: OwnerScope, value: Omit<typeof chatSessions.$inferInsert, "ownerUserId">) => db.insert(chatSessions).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
|
||||
listMessages: (scope: OwnerScope, sessionId: string) => db.select({ message: chatMessages }).from(chatMessages).innerJoin(chatSessions, eq(chatMessages.sessionId, chatSessions.id)).where(and(eq(chatMessages.sessionId, sessionId), eq(chatSessions.ownerUserId, scope.ownerUserId))).orderBy(asc(chatMessages.createdAt)).all().map(({ message }) => message),
|
||||
createMessage: (value: typeof chatMessages.$inferInsert) => db.insert(chatMessages).values(value).returning().get(),
|
||||
},
|
||||
business: {
|
||||
getProposal: (scope: OwnerScope, id: string) => db.select().from(proposals).where(and(eq(proposals.id, id), eq(proposals.ownerUserId, scope.ownerUserId))).get(),
|
||||
createProposal: (scope: OwnerScope, value: Omit<typeof proposals.$inferInsert, "ownerUserId">) => db.insert(proposals).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
|
||||
createContract: (scope: OwnerScope, value: Omit<typeof contracts.$inferInsert, "ownerUserId">) => db.insert(contracts).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
|
||||
createInvoice: (scope: OwnerScope, value: Omit<typeof invoices.$inferInsert, "ownerUserId">) => db.insert(invoices).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
|
||||
createSubscription: (scope: OwnerScope, value: Omit<typeof subscriptions.$inferInsert, "ownerUserId">) => db.insert(subscriptions).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
|
||||
},
|
||||
analytics: {
|
||||
summary: (scope: OwnerScope) => db.select({
|
||||
incomeMinor: sql<number>`coalesce(sum(case when ${financeTransactions.type} = 'income' and ${financeTransactions.paymentStatus} = 'paid' then ${financeTransactions.amountMinor} else 0 end), 0)`,
|
||||
expenseMinor: sql<number>`coalesce(sum(case when ${financeTransactions.type} = 'expense' and ${financeTransactions.paymentStatus} = 'paid' then ${financeTransactions.amountMinor} else 0 end), 0)`,
|
||||
plannedMinor: sql<number>`coalesce(sum(case when ${financeTransactions.paymentStatus} in ('planned', 'pending') then ${financeTransactions.amountMinor} else 0 end), 0)`,
|
||||
}).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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type DomainRepositories = ReturnType<typeof createDomainRepositories>;
|
||||
@@ -0,0 +1,405 @@
|
||||
import { and, count, eq, inArray, ne } from "drizzle-orm";
|
||||
import {
|
||||
chatSessions,
|
||||
journalEntries,
|
||||
projectRevisions,
|
||||
projects,
|
||||
} from "../db/schema/domain";
|
||||
import { requireClientScope, requireOwnerScope, type DomainActor, type OwnerScope } from "../domain/actor";
|
||||
import type { DomainDatabase } from "../domain/database";
|
||||
import { conflict, DomainError, notFound } from "../domain/errors";
|
||||
import { generateId, type IdGenerator } from "../domain/id";
|
||||
import {
|
||||
calendarEventCreateSchema,
|
||||
chatMessageCreateSchema,
|
||||
chatSessionCreateSchema,
|
||||
clientActivityCreateSchema,
|
||||
clientCreateSchema,
|
||||
clientUpdateSchema,
|
||||
contractCreateSchema,
|
||||
financeTransactionCreateSchema,
|
||||
financeTransactionUpdateSchema,
|
||||
invoiceCreateSchema,
|
||||
journalEntrySchema,
|
||||
parseDomainInput,
|
||||
planningSectionCreateSchema,
|
||||
planningSectionUpdateSchema,
|
||||
projectCreateSchema,
|
||||
projectUpdateSchema,
|
||||
proposalCreateSchema,
|
||||
revisionCreateSchema,
|
||||
revisionStatusSchema,
|
||||
subscriptionCreateSchema,
|
||||
taskCreateSchema,
|
||||
taskUpdateSchema,
|
||||
calendarEventUpdateSchema,
|
||||
} from "../domain/validation";
|
||||
import { createDomainRepositories, type DomainRepositories } from "../repositories/domain";
|
||||
|
||||
export class DomainService {
|
||||
readonly repositories: DomainRepositories;
|
||||
|
||||
constructor(
|
||||
private readonly db: DomainDatabase,
|
||||
private readonly id: IdGenerator = generateId,
|
||||
) {
|
||||
this.repositories = createDomainRepositories(db);
|
||||
}
|
||||
|
||||
listClients(actor: DomainActor) {
|
||||
return this.repositories.clients.list(requireOwnerScope(actor));
|
||||
}
|
||||
|
||||
getClient(actor: DomainActor, id: string) {
|
||||
if (actor.role === "client") {
|
||||
const scope = requireClientScope(actor);
|
||||
if (scope.clientId !== id) throw notFound("Müşteri");
|
||||
return this.repositories.clients.getByPortalScope(scope) ?? this.throwNotFound("Müşteri");
|
||||
}
|
||||
return this.repositories.clients.get(requireOwnerScope(actor), id) ?? this.throwNotFound("Müşteri");
|
||||
}
|
||||
|
||||
createClient(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(clientCreateSchema, input);
|
||||
return this.repositories.clients.create(scope, { ...value, id: value.id ?? this.id() });
|
||||
}
|
||||
|
||||
updateClient(actor: DomainActor, id: string, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(clientUpdateSchema, input);
|
||||
return this.repositories.clients.update(scope, id, value) ?? this.throwNotFound("Müşteri");
|
||||
}
|
||||
|
||||
deleteClient(actor: DomainActor, id: string) {
|
||||
return this.repositories.clients.remove(requireOwnerScope(actor), id) ?? this.throwNotFound("Müşteri");
|
||||
}
|
||||
|
||||
addClientActivity(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(clientActivityCreateSchema, input);
|
||||
this.requireOwnedClient(scope, value.clientId);
|
||||
return this.repositories.clients.createActivity(scope, { ...value, id: value.id ?? this.id() });
|
||||
}
|
||||
|
||||
listProjects(actor: DomainActor) {
|
||||
if (actor.role === "client") {
|
||||
return this.repositories.projects.listForClient(requireClientScope(actor));
|
||||
}
|
||||
return this.repositories.projects.list(requireOwnerScope(actor));
|
||||
}
|
||||
|
||||
getProject(actor: DomainActor, id: string) {
|
||||
const project = actor.role === "client"
|
||||
? this.repositories.projects.getForClient(requireClientScope(actor), id)
|
||||
: this.repositories.projects.get(requireOwnerScope(actor), id);
|
||||
return project ?? this.throwNotFound("Proje");
|
||||
}
|
||||
|
||||
createProject(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(projectCreateSchema, input);
|
||||
this.assertProjectClient(scope, value.type, value.clientId);
|
||||
return this.repositories.projects.create(scope, { ...value, id: value.id ?? this.id() });
|
||||
}
|
||||
|
||||
updateProject(actor: DomainActor, projectId: string, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const current = this.repositories.projects.get(scope, projectId) ?? this.throwNotFound("Proje");
|
||||
const value = parseDomainInput(projectUpdateSchema, input);
|
||||
this.assertProjectClient(scope, value.type ?? current.type, value.clientId === undefined ? current.clientId : value.clientId);
|
||||
return this.repositories.projects.update(scope, projectId, value) ?? this.throwNotFound("Proje");
|
||||
}
|
||||
|
||||
deleteProject(actor: DomainActor, id: string) {
|
||||
return this.repositories.projects.remove(requireOwnerScope(actor), id) ?? this.throwNotFound("Proje");
|
||||
}
|
||||
|
||||
listTasks(actor: DomainActor, projectId?: string) {
|
||||
if (actor.role === "client") {
|
||||
const scope = requireClientScope(actor);
|
||||
if (projectId) this.getProject(actor, projectId);
|
||||
return this.repositories.tasks.listPublicForClient(scope, projectId);
|
||||
}
|
||||
const scope = requireOwnerScope(actor);
|
||||
const rows = this.repositories.tasks.list(scope);
|
||||
return projectId ? rows.filter((task) => task.projectId === projectId) : rows;
|
||||
}
|
||||
|
||||
createTask(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(taskCreateSchema, input);
|
||||
this.assertTaskRelations(scope, value);
|
||||
const task = this.repositories.tasks.create(scope, { ...value, id: value.id ?? this.id() });
|
||||
if (task.projectId) this.recalculateProjectProgress(scope, task.projectId);
|
||||
return task;
|
||||
}
|
||||
|
||||
updateTask(actor: DomainActor, taskId: string, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const current = this.repositories.tasks.get(scope, taskId) ?? this.throwNotFound("Görev");
|
||||
const value = parseDomainInput(taskUpdateSchema, input);
|
||||
const merged = { ...current, ...value };
|
||||
this.assertTaskRelations(scope, merged);
|
||||
const task = this.repositories.tasks.update(scope, taskId, value) ?? this.throwNotFound("Görev");
|
||||
if (current.projectId) this.recalculateProjectProgress(scope, current.projectId);
|
||||
if (task.projectId && task.projectId !== current.projectId) this.recalculateProjectProgress(scope, task.projectId);
|
||||
return task;
|
||||
}
|
||||
|
||||
deleteTask(actor: DomainActor, taskId: string) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const task = this.repositories.tasks.remove(scope, taskId) ?? this.throwNotFound("Görev");
|
||||
if (task.projectId) this.recalculateProjectProgress(scope, task.projectId);
|
||||
return task;
|
||||
}
|
||||
|
||||
createCalendarEvent(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(calendarEventCreateSchema, input);
|
||||
this.assertTaskRelations(scope, value);
|
||||
return this.repositories.calendar.create(scope, { ...value, id: value.id ?? this.id() });
|
||||
}
|
||||
|
||||
listCalendarEvents(actor: DomainActor) {
|
||||
return this.repositories.calendar.list(requireOwnerScope(actor));
|
||||
}
|
||||
|
||||
updateCalendarEvent(actor: DomainActor, eventId: string, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const current = this.repositories.calendar.get(scope, eventId) ?? this.throwNotFound("Takvim kaydı");
|
||||
const value = parseDomainInput(calendarEventUpdateSchema, input);
|
||||
const merged = { ...current, ...value };
|
||||
if (merged.endsAt && merged.endsAt < merged.startsAt) {
|
||||
throw new DomainError("VALIDATION_ERROR", "Bitiş zamanı başlangıç zamanından önce olamaz.");
|
||||
}
|
||||
this.assertTaskRelations(scope, merged);
|
||||
return this.repositories.calendar.update(scope, eventId, value) ?? this.throwNotFound("Takvim kaydı");
|
||||
}
|
||||
|
||||
deleteCalendarEvent(actor: DomainActor, eventId: string) {
|
||||
return this.repositories.calendar.remove(requireOwnerScope(actor), eventId) ?? this.throwNotFound("Takvim kaydı");
|
||||
}
|
||||
|
||||
createFinanceTransaction(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(financeTransactionCreateSchema, input);
|
||||
this.assertTaskRelations(scope, value);
|
||||
return this.repositories.finance.create(scope, { ...value, id: value.id ?? this.id() });
|
||||
}
|
||||
|
||||
listFinanceTransactions(actor: DomainActor) {
|
||||
return this.repositories.finance.list(requireOwnerScope(actor));
|
||||
}
|
||||
|
||||
updateFinanceTransaction(actor: DomainActor, transactionId: string, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const current = this.repositories.finance.get(scope, transactionId) ?? this.throwNotFound("Finans kaydı");
|
||||
const value = parseDomainInput(financeTransactionUpdateSchema, input);
|
||||
this.assertTaskRelations(scope, { ...current, ...value });
|
||||
return this.repositories.finance.update(scope, transactionId, value) ?? this.throwNotFound("Finans kaydı");
|
||||
}
|
||||
|
||||
deleteFinanceTransaction(actor: DomainActor, transactionId: string) {
|
||||
return this.repositories.finance.remove(requireOwnerScope(actor), transactionId) ?? this.throwNotFound("Finans kaydı");
|
||||
}
|
||||
|
||||
saveJournalEntry(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(journalEntrySchema, input);
|
||||
const existing = this.repositories.journal.getByDate(scope, value.entryDate);
|
||||
if (existing) return this.repositories.journal.updateByDate(scope, value.entryDate, value);
|
||||
return this.repositories.journal.create(scope, { ...value, id: value.id ?? this.id() });
|
||||
}
|
||||
|
||||
listJournalEntries(actor: DomainActor) {
|
||||
return this.repositories.journal.list(requireOwnerScope(actor));
|
||||
}
|
||||
|
||||
deleteJournalEntry(actor: DomainActor, entryId: string) {
|
||||
return this.repositories.journal.remove(requireOwnerScope(actor), entryId) ?? this.throwNotFound("Günlük kaydı");
|
||||
}
|
||||
|
||||
addPlanningSection(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(planningSectionCreateSchema, input);
|
||||
this.requireOwnedProject(scope, value.projectId);
|
||||
return this.repositories.planning.create(scope, { ...value, id: value.id ?? this.id() });
|
||||
}
|
||||
|
||||
updatePlanningSection(actor: DomainActor, sectionId: string, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
if (!this.repositories.planning.get(scope, sectionId)) throw notFound("Planlama bölümü");
|
||||
const value = parseDomainInput(planningSectionUpdateSchema, input);
|
||||
return this.repositories.planning.update(scope, sectionId, value) ?? this.throwNotFound("Planlama bölümü");
|
||||
}
|
||||
|
||||
deletePlanningSection(actor: DomainActor, sectionId: string) {
|
||||
return this.repositories.planning.remove(requireOwnerScope(actor), sectionId) ?? this.throwNotFound("Planlama bölümü");
|
||||
}
|
||||
|
||||
listPlanningSections(actor: DomainActor, projectId: string) {
|
||||
if (actor.role === "client") {
|
||||
const scope = requireClientScope(actor);
|
||||
this.getProject(actor, projectId);
|
||||
return this.repositories.planning.listForClient(scope, projectId);
|
||||
}
|
||||
const scope = requireOwnerScope(actor);
|
||||
this.requireOwnedProject(scope, projectId);
|
||||
return this.repositories.planning.list(scope, projectId);
|
||||
}
|
||||
|
||||
requestRevision(actor: DomainActor, input: unknown) {
|
||||
const scope = requireClientScope(actor);
|
||||
const value = parseDomainInput(revisionCreateSchema, input);
|
||||
const revisionId = value.id ?? this.id();
|
||||
|
||||
return this.db.transaction((tx) => {
|
||||
const project = tx.select().from(projects).where(and(eq(projects.id, value.projectId), eq(projects.clientId, scope.clientId))).get();
|
||||
if (!project) throw notFound("Proje");
|
||||
if (project.status !== "active") {
|
||||
throw new DomainError("INVARIANT_VIOLATION", "Yalnızca aktif projeler revizyon kabul eder.");
|
||||
}
|
||||
const used = tx.select({ value: count() }).from(projectRevisions).where(and(eq(projectRevisions.projectId, project.id), eq(projectRevisions.clientId, scope.clientId), ne(projectRevisions.status, "rejected"))).get()?.value ?? 0;
|
||||
if (used >= project.revisionQuota) throw conflict("Projenin revizyon kotası doldu.");
|
||||
return tx.insert(projectRevisions).values({
|
||||
id: revisionId,
|
||||
ownerUserId: project.ownerUserId,
|
||||
projectId: project.id,
|
||||
clientId: scope.clientId,
|
||||
requestedByUserId: scope.authUserId,
|
||||
description: value.description,
|
||||
}).returning().get();
|
||||
}, { behavior: "immediate" });
|
||||
}
|
||||
|
||||
updateRevisionStatus(actor: DomainActor, revisionId: string, statusInput: unknown) {
|
||||
const status = parseDomainInput(revisionStatusSchema, statusInput);
|
||||
return this.repositories.revisions.updateStatus(requireOwnerScope(actor), revisionId, status) ?? this.throwNotFound("Revizyon");
|
||||
}
|
||||
|
||||
listRevisions(actor: DomainActor, projectId: string) {
|
||||
if (actor.role === "client") {
|
||||
const scope = requireClientScope(actor);
|
||||
this.getProject(actor, projectId);
|
||||
return this.repositories.revisions.listForClient(scope, projectId);
|
||||
}
|
||||
const scope = requireOwnerScope(actor);
|
||||
this.requireOwnedProject(scope, projectId);
|
||||
return this.repositories.revisions.list(scope, projectId);
|
||||
}
|
||||
|
||||
createChatSession(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(chatSessionCreateSchema, input);
|
||||
return this.repositories.chat.createSession(scope, { ...value, id: value.id ?? this.id() });
|
||||
}
|
||||
|
||||
addChatMessage(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(chatMessageCreateSchema, input);
|
||||
if (!this.repositories.chat.getSession(scope, value.sessionId)) throw notFound("Sohbet");
|
||||
if (value.contextJournalEntryIds.length > 0) {
|
||||
const accessible = this.db.select({ value: count() }).from(journalEntries).where(and(eq(journalEntries.ownerUserId, scope.ownerUserId), inArray(journalEntries.id, value.contextJournalEntryIds))).get()?.value ?? 0;
|
||||
if (accessible !== new Set(value.contextJournalEntryIds).size) throw notFound("Günlük kaydı");
|
||||
}
|
||||
const message = this.repositories.chat.createMessage({ ...value, id: value.id ?? this.id() });
|
||||
this.db.update(chatSessions).set({ updatedAt: new Date() }).where(and(eq(chatSessions.id, value.sessionId), eq(chatSessions.ownerUserId, scope.ownerUserId))).run();
|
||||
return message;
|
||||
}
|
||||
|
||||
getAnalytics(actor: DomainActor) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const finance = this.repositories.analytics.summary(scope) ?? { incomeMinor: 0, expenseMinor: 0, plannedMinor: 0 };
|
||||
return {
|
||||
finance: { ...finance, netMinor: finance.incomeMinor - finance.expenseMinor },
|
||||
projectsByStatus: this.repositories.analytics.projectStatusCounts(scope),
|
||||
tasksByStatus: this.repositories.analytics.taskStatusCounts(scope),
|
||||
};
|
||||
}
|
||||
|
||||
createProposal(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(proposalCreateSchema, input);
|
||||
this.assertTaskRelations(scope, value);
|
||||
return this.repositories.business.createProposal(scope, { ...value, id: value.id ?? this.id() });
|
||||
}
|
||||
|
||||
createContract(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(contractCreateSchema, input);
|
||||
if (value.clientId) this.requireOwnedClient(scope, value.clientId);
|
||||
if (value.proposalId && !this.repositories.business.getProposal(scope, value.proposalId)) {
|
||||
throw notFound("Teklif");
|
||||
}
|
||||
return this.repositories.business.createContract(scope, { ...value, id: value.id ?? this.id() });
|
||||
}
|
||||
|
||||
createInvoice(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(invoiceCreateSchema, input);
|
||||
this.assertTaskRelations(scope, value);
|
||||
return this.repositories.business.createInvoice(scope, { ...value, id: value.id ?? this.id() });
|
||||
}
|
||||
|
||||
createSubscription(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(subscriptionCreateSchema, input);
|
||||
return this.repositories.business.createSubscription(scope, { ...value, id: value.id ?? this.id() });
|
||||
}
|
||||
|
||||
private requireOwnedClient(scope: OwnerScope, clientId: string) {
|
||||
return this.repositories.clients.get(scope, clientId) ?? this.throwNotFound("Müşteri");
|
||||
}
|
||||
|
||||
private requireOwnedProject(scope: OwnerScope, projectId: string) {
|
||||
return this.repositories.projects.get(scope, projectId) ?? this.throwNotFound("Proje");
|
||||
}
|
||||
|
||||
private assertProjectClient(scope: OwnerScope, type: string, clientId: string | null | undefined) {
|
||||
if (type === "side_project" && clientId) {
|
||||
throw new DomainError("INVARIANT_VIOLATION", "Yan projeler bir müşteriye bağlanamaz.");
|
||||
}
|
||||
if (clientId) this.requireOwnedClient(scope, clientId);
|
||||
}
|
||||
|
||||
private assertTaskRelations(scope: OwnerScope, value: {
|
||||
clientId?: string | null;
|
||||
projectId?: string | null;
|
||||
taskId?: string | null;
|
||||
sourceJournalEntryId?: string | null;
|
||||
}) {
|
||||
const client = value.clientId ? this.requireOwnedClient(scope, value.clientId) : null;
|
||||
const project = value.projectId ? this.requireOwnedProject(scope, value.projectId) : null;
|
||||
if (project?.clientId && client?.id && project.clientId !== client.id) {
|
||||
throw new DomainError("INVARIANT_VIOLATION", "Proje ve müşteri ilişkisi uyuşmuyor.");
|
||||
}
|
||||
if (project?.clientId && !client) {
|
||||
throw new DomainError("INVARIANT_VIOLATION", "Müşteri projesine bağlı kayıt müşteri kimliğini içermelidir.");
|
||||
}
|
||||
if (value.taskId) {
|
||||
const task = this.repositories.tasks.get(scope, value.taskId) ?? this.throwNotFound("Görev");
|
||||
if (value.projectId && task.projectId && value.projectId !== task.projectId) {
|
||||
throw new DomainError("INVARIANT_VIOLATION", "Etkinlik ve görev proje ilişkisi uyuşmuyor.");
|
||||
}
|
||||
if (value.clientId && task.clientId && value.clientId !== task.clientId) {
|
||||
throw new DomainError("INVARIANT_VIOLATION", "Etkinlik ve görev müşteri ilişkisi uyuşmuyor.");
|
||||
}
|
||||
}
|
||||
if (value.sourceJournalEntryId && !this.repositories.journal.get(scope, value.sourceJournalEntryId)) {
|
||||
throw notFound("Günlük kaydı");
|
||||
}
|
||||
}
|
||||
|
||||
private recalculateProjectProgress(scope: OwnerScope, projectId: string) {
|
||||
const project = this.repositories.projects.get(scope, projectId);
|
||||
if (!project || project.progressType !== "auto") return;
|
||||
const counts = this.repositories.tasks.progressCounts(scope, projectId);
|
||||
const progress = counts?.total ? Math.round((Number(counts.done) / counts.total) * 100) : 0;
|
||||
this.repositories.projects.update(scope, projectId, { progress });
|
||||
}
|
||||
|
||||
private throwNotFound(resource: string): never {
|
||||
throw notFound(resource);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import "server-only";
|
||||
|
||||
import { getSqliteConnection } from "../db/client";
|
||||
import { DomainService } from "./domain";
|
||||
|
||||
export function getDomainService(): DomainService {
|
||||
return new DomainService(getSqliteConnection().db);
|
||||
}
|
||||
Reference in New Issue
Block a user