feat(domain): complete phase 2 backend core

This commit is contained in:
poyrazavsever
2026-07-16 16:46:42 +03:00
parent 92bc99ba12
commit b155132acf
28 changed files with 5343 additions and 30 deletions
+30
View File
@@ -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 },
);
}
+13
View File
@@ -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,
};
}
+50 -1
View File
@@ -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
View File
@@ -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
+7
View File
@@ -22,6 +22,13 @@
"when": 1784205329112,
"tag": "0002_mighty_korg",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1784208712933,
"tag": "0003_chief_excalibur",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -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))`;
+474
View File
@@ -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
View File
@@ -1,2 +1,3 @@
export * from "./auth";
export * from "./domain";
export * from "./runtime";
+46
View File
@@ -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ışı.");
}
}
+4
View File
@@ -0,0 +1,4 @@
import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
import * as schema from "../db/schema";
export type DomainDatabase = BetterSQLite3Database<typeof schema>;
+38
View File
@@ -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);
}
+5
View File
@@ -0,0 +1,5 @@
import { randomUUID } from "node:crypto";
export type IdGenerator = () => string;
export const generateId: IdGenerator = randomUUID;
+50
View File
@@ -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];
+230
View File
@@ -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;
}
+138
View File
@@ -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>;
+405
View File
@@ -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);
}
}
+8
View File
@@ -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);
}