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
+8 -1
View File
@@ -31,7 +31,14 @@ export async function POST(request: Request) {
function invitationErrorResponse(error: unknown) { function invitationErrorResponse(error: unknown) {
if (error instanceof PortalInvitationError) { if (error instanceof PortalInvitationError) {
const status = error.code === "FORBIDDEN" ? 403 : error.code === "INVALID_INPUT" ? 400 : 409; const status =
error.code === "FORBIDDEN"
? 403
: error.code === "INVALID_INPUT"
? 400
: error.code === "CLIENT_NOT_FOUND"
? 404
: 409;
return NextResponse.json({ error: error.message, code: error.code }, { status }); return NextResponse.json({ error: error.message, code: error.code }, { status });
} }
@@ -248,7 +248,7 @@ Checklist:
- [x] Davet iptal edilebiliyor. - [x] Davet iptal edilebiliyor.
- [x] Aynı müşteri için aktif davet politikası tanımlandı. - [x] Aynı müşteri için aktif davet politikası tanımlandı.
- [x] Kabul işlemi transaction içinde. - [x] Kabul işlemi transaction içinde.
- [x] Client profile ve client kimlik bağı atomik kuruluyor (`app_profiles.client_id`; domain FK Faz 2'de eklenecek). - [x] Client profile ve client kimlik bağı atomik kuruluyor (`app_profiles.client_id` + `clients.auth_user_id`).
- [x] Kullanılmış veya süresi dolmuş token tekrar kullanılamıyor. - [x] Kullanılmış veya süresi dolmuş token tekrar kullanılamıyor.
- [x] Client hesabı disable/revoke edilebiliyor. - [x] Client hesabı disable/revoke edilebiliyor.
@@ -567,14 +567,14 @@ Transaction içinde doğrulanacaklar:
Checklist: Checklist:
- [ ] `project_id` ve `client_id` eşleşmesi server-side doğrulanıyor. - [x] `project_id` ve `client_id` eşleşmesi server-side doğrulanıyor.
- [ ] İstemciden gelen `clientId` güven kaynağı olarak kullanılmıyor. - [x] İstemciden gelen `clientId` güven kaynağı olarak kullanılmıyor.
- [ ] Quota server-side kontrol ediliyor. - [x] Quota server-side kontrol ediliyor.
- [ ] Quota atomik azaltılıyor veya tüketim kayıtlarından hesaplanıyor. - [x] Quota atomik azaltılıyor veya tüketim kayıtlarından hesaplanıyor.
- [ ] Başarısız insert quota tüketmiyor. - [x] Başarısız insert quota tüketmiyor.
- [ ] Cross-project revision negatif testi var. - [x] Cross-project revision negatif testi var.
- [ ] Başka client adına revision oluşturma negatif testi var. - [x] Başka client adına revision oluşturma negatif testi var.
- [ ] Kota aşımı negatif testi var. - [x] Kota aşımı negatif testi var.
## 16. API v1 ve mobil hazırlığı ## 16. API v1 ve mobil hazırlığı
@@ -600,13 +600,13 @@ DELETE /api/v1/device-sessions/:id
Mobil hazırlık checklist'i: Mobil hazırlık checklist'i:
- [ ] API response envelope standardı tanımlandı. - [x] API response envelope standardı tanımlandı.
- [ ] API hata kodları tanımlandı. - [x] API hata kodları tanımlandı.
- [ ] API sürümleme stratejisi tanımlandı. - [ ] API sürümleme stratejisi tanımlandı.
- [ ] Instance metadata sözleşmesi tanımlandı. - [ ] Instance metadata sözleşmesi tanımlandı.
- [ ] Minimum desteklenen client sürümü alanı düşünüldü. - [ ] Minimum desteklenen client sürümü alanı düşünüldü.
- [ ] Capability listesi sözleşmesi düşünüldü. - [ ] Capability listesi sözleşmesi düşünüldü.
- [ ] Service katmanı cookie/Next.js objelerine bağımlı değil. - [x] Service katmanı cookie/Next.js objelerine bağımlı değil.
- [ ] Mobil pairing ilk release kapsamı dışında tutuldu. - [ ] Mobil pairing ilk release kapsamı dışında tutuldu.
- [ ] Gelecekte HTTPS zorunluluğu belgelendi. - [ ] Gelecekte HTTPS zorunluluğu belgelendi.
@@ -714,9 +714,9 @@ Mümkün olduğunda küçük ve doğrudan test araçları tercih edilir; test al
- [x] Client invitation testi - [x] Client invitation testi
- [x] Expired/revoked invitation negatif testi - [x] Expired/revoked invitation negatif testi
- [ ] Her repository için cross-owner negatif test - [ ] Her repository için cross-owner negatif test
- [ ] Client private task erişim negatif testi - [x] Client private task erişim negatif testi
- [ ] Revision project-client eşleşme negatif testi - [x] Revision project-client eşleşme negatif testi
- [ ] Revision quota testi - [x] Revision quota testi
- [ ] File upload MIME/size testi - [ ] File upload MIME/size testi
- [ ] Path traversal negatif testi - [ ] Path traversal negatif testi
- [x] Backup oluşturma testi - [x] Backup oluşturma testi
@@ -820,13 +820,22 @@ Faz 1 tamamlanma notu (2026-07-16):
Amaç: Tüm çekirdek iş verileri için Drizzle schema, migration, repository ve service katmanını kurmak. Amaç: Tüm çekirdek iş verileri için Drizzle schema, migration, repository ve service katmanını kurmak.
- [ ] Çekirdek tablolar oluşturuldu. - [x] Çekirdek tablolar oluşturuldu.
- [ ] Repository katmanı oluşturuldu. - [x] Repository katmanı oluşturuldu.
- [ ] Service katmanı oluşturuldu. - [x] Service katmanı oluşturuldu.
- [ ] Actor/authorization sözleşmesi standartlaştırıldı. - [x] Actor/authorization sözleşmesi standartlaştırıldı.
- [ ] Validation ve error sözleşmesi standartlaştırıldı. - [x] Validation ve error sözleşmesi standartlaştırıldı.
- [ ] Negatif authorization testleri yazıldı. - [x] Negatif authorization testleri yazıldı.
- [ ] Analytics aggregate sorgu yaklaşımı belirlendi. - [x] Analytics aggregate sorgu yaklaşımı belirlendi.
Faz 2 tamamlanma notu (2026-07-16):
- 11 çekirdek domain tablosu ile kaynak verisi korunacak 4 business tablosu Drizzle schema ve migration'a eklendi; storage ve branding tabloları Faz 3 sınırında bırakıldı.
- Scope zorunlu repository katmanı ve Next.js/session bağımsız service katmanı; CRUD, ilişki tutarlılığı, otomatik proje ilerlemesi, portal görünürlüğü ve aggregate sorguları uygular.
- Davet hedefi yerel owner-scoped client kaydına bağlandı. Kabul transaction'ı `app_profiles.client_id` ve `clients.auth_user_id` kimlik bağlarını birlikte kurar; client session bu iki yönlü bağı doğrular.
- Revizyon isteği `BEGIN IMMEDIATE` transaction içinde actor-derived client, proje ilişkisi, aktif proje ve tüketim kayıtlarından kota kontrolüyle oluşturulur.
- `phase2:domain-smoke`; cross-owner, client owner-only erişimi, private task, başka client/proje, kota aşımı, ilişkisel owner ve SQLite constraint negatiflerini gerçek migration uygulanmış veritabanında doğrular.
- Tasarım ve doğrulama ayrıntıları `phase-2-domain-core.md` belgesinde kaydedildi.
Çıkış kriteri: Çekirdek domain işlemleri UI veya Supabase'e bağımlı olmadan test edilebiliyor. Çıkış kriteri: Çekirdek domain işlemleri UI veya Supabase'e bağımlı olmadan test edilebiliyor.
+2 -2
View File
@@ -34,12 +34,12 @@ Bu dosya tarihsel adı korunarak Faz 1'de tamamlanan Better Auth + SQLite auth t
- Token `randomBytes(32)` ile üretilir; SQLite'ta yalnızca SHA-256 hash saklanır. - Token `randomBytes(32)` ile üretilir; SQLite'ta yalnızca SHA-256 hash saklanır.
- Varsayılan TTL 72 saat, servis üst sınırı 168 saattir. - Varsayılan TTL 72 saat, servis üst sınırı 168 saattir.
- Aynı `clientId` için yeni davet önceki pending davetleri revoke eder ve bu değişiklik audit edilir. - Aynı `clientId` için yeni davet önceki pending davetleri revoke eder ve bu değişiklik audit edilir.
- Kabul sırasında Better Auth `user`, credential `account`, `app_profiles` client kaydı, `client_id` identity bağı ve invitation `accepted` durumu tek SQLite transaction'ında yazılır. - Kabul sırasında Better Auth `user`, credential `account`, `app_profiles` client kaydı, `clients.auth_user_id` bağı ve invitation `accepted` durumu tek SQLite transaction'ında yazılır.
- Kullanılmış, değiştirilmiş, süresi dolmuş veya revoke edilmiş token yeniden kullanılamaz. - Kullanılmış, değiştirilmiş, süresi dolmuş veya revoke edilmiş token yeniden kullanılamaz.
- Disable işlemi profile'ı kapatır ve o client'ın aktif Better Auth session kayıtlarını aynı transaction'da siler. - Disable işlemi profile'ı kapatır ve o client'ın aktif Better Auth session kayıtlarını aynı transaction'da siler.
- Disabled veya `client_id` bağı eksik client, Better Auth endpoint'ini doğrudan çağırsa bile session oluşturamaz. - Disabled veya `client_id` bağı eksik client, Better Auth endpoint'ini doğrudan çağırsa bile session oluşturamaz.
`app_profiles.client_id`, Faz 1 auth sınırında opaque domain identity bağıdır. Yerel `clients` tablosu ve foreign key Faz 2 domain migration'ında eklenecektir; mevcut Supabase client sayfaları bu nedenle henüz domain açısından hibrittir. Faz 2 ile yerel `clients` tablosu eklenmiştir. Davet hedefi artık owner'a ait gerçek bir client kaydı olmak zorundadır; kabul transaction'ı `app_profiles.client_id` ile `clients.auth_user_id` bağlarını birlikte kurar ve session çözümlemesi iki yönlü bağın eşleştiğini doğrular. Mevcut ekran sorgularının Supabase'ten service katmanına taşınması sayfa bazlı dönüşüm fazlarında sürdürülecektir.
## Audit kapsamı ## Audit kapsamı
@@ -0,0 +1,94 @@
---
title: Faz 2 Domain Schema ve Backend Çekirdeği
description: SQLite/Drizzle domain modeli, repository-service sınırı, actor yetkilendirmesi ve test sözleşmesi.
status: complete
last_updated: 2026-07-16
---
# Faz 2 Domain Schema ve Backend Çekirdeği
Faz 2, Neta'nın iş verilerini Supabase istemcisinden ayıran çalıştırılabilir backend çekirdeğini kurar. Bu faz sayfa sorgularını henüz taşımaz; Drizzle schema, migration, repository ve service katmanları UI, cookie ve Next.js request objelerinden bağımsızdır. Route Handler veya Server Action yalnızca session'ı `DomainActor`'a çevirip service çağırmalıdır.
## Veri modeli
`0003_chief_excalibur.sql` migration'ı 15 domain tablosunu ekler:
- çekirdek: `clients`, `client_activities`, `projects`, `project_planning_sections`, `tasks`, `calendar_events`, `finance_transactions`, `journal_entries`, `project_revisions`, `chat_sessions`, `chat_messages`;
- korunacak iş verileri: `proposals`, `contracts`, `invoices`, `subscriptions`.
Dosya metadata'sı ve instance branding bilinçli olarak Faz 3'e bırakılmıştır. ID'ler mevcut UUID'leri taşıyabilmek için `text`, parasal değerler integer minor unit, iş tarihleri `YYYY-MM-DD`, sistem zamanları UTC epoch millisecond olarak saklanır. Owner'a ait tablolarda açık `owner_user_id`, client portal bağında `clients.auth_user_id` bulunur.
Schema seviyesinde status/type enumları, negatif para ve süre değerleri, progress aralığı, revizyon kotası, tarih aralığı, currency uzunluğu, journal owner+date tekilliği ve invoice owner+number tekilliği SQLite `CHECK`/unique constraint'leriyle korunur. İlişkisel owner tutarlılığı service katmanında doğrulanır; istemciden gelen owner veya client kimliği güven kaynağı değildir.
## Katman sınırları
| Katman | Sorumluluk | Bağımlı olmadığı şeyler |
| --- | --- | --- |
| Schema | tablo, foreign key, index ve DB constraint | UI, Supabase |
| Repository | scope uygulanmış Drizzle sorguları ve aggregate'ler | session/cookie, Next.js |
| Service | validation, actor yetkisi, ilişki ve iş kuralları | Route Handler, React |
| Adapter | session→actor ve HTTP response dönüşümü | domain kuralı |
Ana giriş noktaları:
- `server/domain/actor.ts`: `DomainActor`, `OwnerScope`, `ClientScope` ve role/disabled guard'ları;
- `server/domain/validation.ts`: paylaşılan Zod input sözleşmeleri;
- `server/domain/errors.ts`: stabil domain error code ve HTTP status eşlemesi;
- `server/repositories/domain.ts`: owner/client scope'u sorgu koşuluna dönüştüren repository'ler;
- `server/services/domain.ts`: CRUD, ilişki doğrulaması, portal görünürlüğü, revizyon ve aggregate kuralları;
- `server/api/responses.ts`: `{ ok, data }` ve `{ ok, error }` API envelope'u;
- `server/auth/domain-actor.ts`: web session adapter'ı.
Repository metoduna çıplak `ownerUserId` yerine tiplenmiş scope verilir. Owner kaynaklarında kimlik filtresi her sorguda uygulanır. Client proje erişimi bağlı `clientId`, görev erişimi ayrıca `is_public_to_client = true` üzerinden kısıtlanır. Calendar, finance, journal ve chat client rolüne kapalıdır.
## İş kuralları
- Side project bir client'a bağlanamaz.
- Client/project/task/journal ilişkileri aynı owner altında bulunmalı ve birbiriyle uyuşmalıdır.
- Otomatik progress kullanan projeler, iptal edilmemiş görevlerdeki `done / total` oranından create/update/delete sonrasında yeniden hesaplanır.
- Journal aynı owner ve iş tarihi için upsert edilir.
- Chat session ve journal context kayıtları aynı owner'a ait olmak zorundadır.
- Business preservation tablolarına yazılan client/project/proposal bağları owner scope'unda doğrulanır.
- Davet yalnızca owner'a ait gerçek bir `clients` kaydı için üretilebilir. Kabul işlemi auth kayıtlarıyla birlikte `clients.auth_user_id` değerini aynı transaction'da yazar; session çözümlemesi `app_profiles.client_id` ile bu bağı karşılıklı doğrular.
## Revizyon transaction'ı
Revizyon isteği client actor'dan `clientId` almaz; client kimliği actor scope'undan gelir. `BEGIN IMMEDIATE` transaction içinde proje-client eşleşmesi, projenin aktif olması ve reddedilmemiş tüketim kayıtlarından kalan kota kontrol edilir, sonra insert yapılır. Bu yaklaşım ayrı bir mutable sayaç tutmaz; başarısız transaction kota tüketmez ve eşzamanlı yazarlar kontrol ile insert arasına giremez.
## Analytics yaklaşımı
Analytics için satırların tamamını belleğe alıp JavaScript'te toplamak yerine repository seviyesinde doğrudan SQLite aggregate sorguları kullanılır:
- ödenmiş gelir, ödenmiş gider ve planlanan/pending tutarlar koşullu `SUM` ile;
- proje ve görev durum dağılımları `GROUP BY` + `COUNT` ile;
- tüm sorgular `owner_user_id` scope'u ile.
İleride dashboard zaman serileri de aynı yaklaşımda tarih aralığı ve currency filtresi eklenerek genişletilmelidir. Farklı para birimleri kur bilgisi olmadan birbirine çevrilmemelidir.
## Doğrulama
`npm run phase2:domain-smoke` her çalışmada boş bir SQLite dosyasına gerçek migration'ları uygular, saf TypeScript domain çekirdeğini derler ve aşağıdaki senaryoları doğrular:
- owner CRUD scope'u ve cross-owner kaynak reddi;
- client'ın owner-only modüllerden reddi;
- bağlı proje/planlama görünürlüğü ve private task sızıntısının engellenmesi;
- otomatik project progress;
- project-client eşleşmesi, aktif proje kuralı, atomik quota ve quota aşımı;
- journal upsert, chat ownership ve ilişkisel owner doğrulamaları;
- owner-scope finance aggregate sonuçları;
- korunacak dört business tablosuna service üzerinden yazım;
- negatif amount ve geçersiz status için SQLite CHECK constraint'leri.
Faz 1 auth smoke'u da yerel client fixture'larıyla çalışır ve davet kabulünden sonra hem profile hem `clients.auth_user_id` bağını doğrular.
| Kontrol | Sonuç |
| --- | --- |
| `npm run typecheck` | Başarılı |
| Değişen Faz 2 dosyalarında targeted ESLint | 0 error, 0 warning |
| `npm run phase2:domain-smoke` | Başarılı |
| `node scripts/phase1-auth-smoke.mjs` | Başarılı |
| `node scripts/phase1-smoke.mjs` | Başarılı; migration, backup ve restore dahil |
| `pnpm db:generate` | Schema drift yok |
| `npm run build` | Başarılı |
Repo geneli lint, Faz 0'dan kaydedilmiş ve bu fazın değiştirmediği UI/AI dosyalarındaki baseline nedeniyle 31 error ve 18 warning ile açık kalır. Faz 2 dosyaları bu bulgulara yenisini eklemez.
+1
View File
@@ -14,6 +14,7 @@
"db:restore": "node scripts/restore.mjs", "db:restore": "node scripts/restore.mjs",
"phase1:smoke": "node scripts/phase1-smoke.mjs", "phase1:smoke": "node scripts/phase1-smoke.mjs",
"phase1:auth-smoke": "node scripts/phase1-auth-smoke.mjs", "phase1:auth-smoke": "node scripts/phase1-auth-smoke.mjs",
"phase2:domain-smoke": "node scripts/phase2-domain-smoke.mjs",
"phase2:smoke": "node scripts/phase2-auth-smoke.mjs", "phase2:smoke": "node scripts/phase2-auth-smoke.mjs",
"phase3:ui-boundary": "node scripts/phase3-ui-boundary.mjs" "phase3:ui-boundary": "node scripts/phase3-ui-boundary.mjs"
}, },
+27
View File
@@ -78,6 +78,20 @@ try {
"Exactly one freelancer profile must exist", "Exactly one freelancer profile must exist",
); );
const ownerUserId = db
.prepare("select auth_user_id as authUserId from app_profiles where role = 'freelancer'")
.get().authUserId;
const insertClient = db.prepare(
"insert into clients (id, owner_user_id, name) values (?, ?, ?)",
);
for (const [clientId, name] of [
["client-alpha", "Alpha Client"],
["client-expired", "Expired Client"],
["client-revoked", "Revoked Client"],
]) {
insertClient.run(clientId, ownerUserId, name);
}
const rejectedRegistration = await authPost("/api/auth/sign-up/email", { const rejectedRegistration = await authPost("/api/auth/sign-up/email", {
name: "Public Attacker", name: "Public Attacker",
email: "attacker@example.com", email: "attacker@example.com",
@@ -110,6 +124,13 @@ try {
}); });
assert.equal(invalidInvite.response.status, 400, "Invalid invitation input must fail"); assert.equal(invalidInvite.response.status, 400, "Invalid invitation input must fail");
const missingClientInvite = await jsonRequest("/api/portal-invitations", {
method: "POST",
cookie: ownerCookie,
body: { clientId: "missing-client", email: "missing@example.com" },
});
assert.equal(missingClientInvite.response.status, 404, "Invitation target must be an owned client");
const firstInvite = await jsonRequest("/api/portal-invitations", { const firstInvite = await jsonRequest("/api/portal-invitations", {
method: "POST", method: "POST",
cookie: ownerCookie, cookie: ownerCookie,
@@ -150,6 +171,12 @@ try {
.get("client@example.com"), .get("client@example.com"),
{ role: "client", clientId: "client-alpha", disabled: 0 }, { role: "client", clientId: "client-alpha", disabled: 0 },
); );
assert.equal(
db.prepare("select auth_user_id as authUserId from clients where id = ?").get("client-alpha")
.authUserId,
clientAuthUserId,
"Accepted invitation must atomically link the domain client",
);
assert.notEqual( assert.notEqual(
db.prepare("select password from account where user_id = ?").get(clientAuthUserId).password, db.prepare("select password from account where user_id = ?").get(clientAuthUserId).password,
"Client-Password-123", "Client-Password-123",
+19
View File
@@ -0,0 +1,19 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const dataDir = path.join(process.cwd(), ".data", `phase2-domain-smoke-${Date.now()}`);
const databasePath = path.join(dataDir, "neta.db");
const env = { ...process.env, DATA_DIR: dataDir, DATABASE_PATH: databasePath };
fs.mkdirSync(dataDir, { recursive: true });
execFileSync(process.execPath, ["scripts/migrate.mjs"], { cwd: process.cwd(), env, stdio: "inherit" });
execFileSync("./node_modules/.bin/tsc", ["-p", "tsconfig.phase2-smoke.json"], {
cwd: process.cwd(),
stdio: "inherit",
});
execFileSync(
process.execPath,
[path.join(".next", "phase2-domain-smoke-dist", "scripts", "phase2-domain-smoke.js"), databasePath],
{ cwd: process.cwd(), stdio: "inherit" },
);
+162
View File
@@ -0,0 +1,162 @@
import assert from "node:assert/strict";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { eq } from "drizzle-orm";
import * as schema from "../server/db/schema";
import type { DomainActor } from "../server/domain/actor";
import { DomainError } from "../server/domain/errors";
import { DomainService } from "../server/services/domain";
const databasePath = process.argv[2];
assert.ok(databasePath, "Database path is required");
const sqlite = new Database(databasePath);
sqlite.pragma("foreign_keys = ON");
const db = drizzle({ client: sqlite, schema });
let generatedId = 0;
const service = new DomainService(db, () => `generated-${++generatedId}`);
const ownerOne: DomainActor = { authUserId: "owner-1", role: "freelancer", clientId: null, disabled: false };
const ownerTwo: DomainActor = { authUserId: "owner-2", role: "freelancer", clientId: null, disabled: false };
const clientOne: DomainActor = { authUserId: "client-user-1", role: "client", clientId: "client-1", disabled: false };
const clientTwo: DomainActor = { authUserId: "client-user-2", role: "client", clientId: "client-2", disabled: false };
try {
for (const actor of [ownerOne, ownerTwo, clientOne, clientTwo]) {
db.insert(schema.user).values({
id: actor.authUserId,
name: actor.authUserId,
email: `${actor.authUserId}@example.com`,
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
}).run();
}
service.createClient(ownerOne, { id: "client-1", name: "Client One", email: "one@example.com" });
service.createClient(ownerOne, { id: "client-2", name: "Client Two" });
service.createClient(ownerTwo, { id: "client-other", name: "Other Owner Client" });
db.update(schema.clients).set({ authUserId: clientOne.authUserId }).where(eq(schema.clients.id, "client-1")).run();
db.update(schema.clients).set({ authUserId: clientTwo.authUserId }).where(eq(schema.clients.id, "client-2")).run();
assertDomainError(() => service.getClient(ownerTwo, "client-1"), "NOT_FOUND");
assertDomainError(() => service.listClients(clientOne), "FORBIDDEN");
assert.equal(service.getClient(clientOne, "client-1").id, "client-1");
assertDomainError(() => service.getClient(clientOne, "client-2"), "NOT_FOUND");
assertDomainError(
() => service.createProject(ownerOne, { id: "invalid-side", name: "Invalid", type: "side_project", clientId: "client-1" }),
"INVARIANT_VIOLATION",
);
service.createProject(ownerOne, {
id: "project-1",
name: "Client Project",
clientId: "client-1",
status: "active",
progressType: "auto",
revisionQuota: 1,
});
service.createProject(ownerOne, { id: "project-2", name: "Second Client", clientId: "client-2" });
service.createProject(ownerTwo, { id: "project-other", name: "Other Project", clientId: "client-other" });
service.createTask(ownerOne, {
id: "task-public",
title: "Public Task",
clientId: "client-1",
projectId: "project-1",
status: "done",
isPublicToClient: true,
});
service.createTask(ownerOne, {
id: "task-private",
title: "Private Task",
clientId: "client-1",
projectId: "project-1",
status: "todo",
isPublicToClient: false,
});
assert.equal(service.getProject(ownerOne, "project-1").progress, 50, "Auto progress must aggregate active tasks");
assert.deepEqual(service.listTasks(clientOne).map((task) => task.id), ["task-public"]);
assertDomainError(() => service.getProject(clientOne, "project-2"), "NOT_FOUND");
assertDomainError(() => service.listFinanceTransactions(clientOne), "FORBIDDEN");
assertDomainError(() => service.updateTask(ownerTwo, "task-public", { status: "done" }), "NOT_FOUND");
service.updateTask(ownerOne, "task-private", { status: "done" });
assert.equal(service.getProject(ownerOne, "project-1").progress, 100);
service.addPlanningSection(ownerOne, {
id: "planning-1",
projectId: "project-1",
category: "overview",
title: "Overview",
content: "Visible project context",
});
assert.equal(service.listPlanningSections(clientOne, "project-1").length, 1);
assertDomainError(() => service.listPlanningSections(clientTwo, "project-1"), "NOT_FOUND");
assert.equal(service.requestRevision(clientOne, { id: "revision-1", projectId: "project-1", description: "Please revise" }).status, "pending");
assertDomainError(
() => service.requestRevision(clientOne, { id: "revision-2", projectId: "project-1", description: "Quota overflow" }),
"CONFLICT",
);
assertDomainError(
() => service.requestRevision(clientTwo, { id: "revision-3", projectId: "project-1", description: "Wrong client" }),
"NOT_FOUND",
);
assert.equal(service.updateRevisionStatus(ownerOne, "revision-1", "completed").status, "completed");
assert.deepEqual(service.listRevisions(clientOne, "project-1").map((revision) => revision.id), ["revision-1"]);
service.createFinanceTransaction(ownerOne, {
id: "income-1", type: "income", amountMinor: 150_00, currency: "try", transactionDate: "2026-07-16", paymentStatus: "paid",
});
service.createFinanceTransaction(ownerOne, {
id: "expense-1", type: "expense", amountMinor: 40_00, currency: "TRY", transactionDate: "2026-07-16", paymentStatus: "paid",
});
service.createFinanceTransaction(ownerOne, {
id: "planned-1", type: "income", amountMinor: 75_00, currency: "TRY", transactionDate: "2026-07-17", paymentStatus: "planned",
});
service.createFinanceTransaction(ownerTwo, {
id: "other-income", type: "income", amountMinor: 999_00, currency: "TRY", transactionDate: "2026-07-16", paymentStatus: "paid",
});
assert.deepEqual(service.getAnalytics(ownerOne).finance, {
incomeMinor: 150_00,
expenseMinor: 40_00,
plannedMinor: 75_00,
netMinor: 110_00,
});
service.saveJournalEntry(ownerOne, { id: "journal-1", entryDate: "2026-07-16", moodScore: 3, note: "First" });
service.saveJournalEntry(ownerOne, { entryDate: "2026-07-16", moodScore: 5, note: "Updated" });
assert.equal(service.listJournalEntries(ownerOne).length, 1, "Journal date must upsert per owner");
assert.equal(service.listJournalEntries(ownerOne)[0]?.moodScore, 5);
assertDomainError(
() => service.createTask(ownerTwo, { title: "Foreign journal", sourceJournalEntryId: "journal-1" }),
"NOT_FOUND",
);
service.createChatSession(ownerOne, { id: "chat-1", title: "Daily review" });
service.addChatMessage(ownerOne, { id: "message-1", sessionId: "chat-1", role: "user", content: "Summarize", contextJournalEntryIds: ["journal-1"] });
assertDomainError(() => service.addChatMessage(ownerTwo, { sessionId: "chat-1", role: "user", content: "Cross owner" }), "NOT_FOUND");
service.createProposal(ownerOne, { id: "proposal-1", clientId: "client-1", projectId: "project-1", title: "Proposal", amountMinor: 100_00 });
service.createContract(ownerOne, { id: "contract-1", clientId: "client-1", title: "Contract" });
service.createInvoice(ownerOne, { id: "invoice-1", clientId: "client-1", projectId: "project-1", invoiceNumber: "INV-001", amountMinor: 100_00, issueDate: "2026-07-16" });
service.createSubscription(ownerOne, { id: "subscription-1", name: "Hosting", amountMinor: 500_00 });
assert.throws(
() => sqlite.prepare("insert into finance_transactions (id, owner_user_id, type, amount_minor, currency, transaction_date, payment_status) values (?, ?, ?, ?, ?, ?, ?)").run("invalid-finance", ownerOne.authUserId, "income", -1, "TRY", "2026-07-16", "paid"),
/CHECK constraint failed/,
);
assert.throws(
() => sqlite.prepare("insert into tasks (id, owner_user_id, title, status, priority) values (?, ?, ?, ?, ?)").run("invalid-task", ownerOne.authUserId, "Invalid", "unknown", "medium"),
/CHECK constraint failed/,
);
console.log("Phase 2 domain smoke passed: scope, invariants, quota, aggregates and DB constraints verified.");
} finally {
sqlite.close();
}
function assertDomainError(run: () => unknown, code: DomainError["code"]) {
assert.throws(run, (error) => error instanceof DomainError && error.code === code);
}
+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 { createHash, randomBytes, randomUUID } from "node:crypto";
import { hashPassword } from "better-auth/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 { z } from "zod";
import type { SessionContext } from "@/server/auth/session"; import type { SessionContext } from "@/server/auth/session";
import { getServerConfig } from "@/server/config"; import { getServerConfig } from "@/server/config";
@@ -11,6 +11,7 @@ import {
account, account,
appProfiles, appProfiles,
authAuditEvents, authAuditEvents,
clients,
portalInvitations, portalInvitations,
session, session,
user, user,
@@ -37,6 +38,7 @@ export type PortalInvitationErrorCode =
| "INVITATION_NOT_FOUND" | "INVITATION_NOT_FOUND"
| "INVITATION_NOT_PENDING" | "INVITATION_NOT_PENDING"
| "INVITATION_EXPIRED" | "INVITATION_EXPIRED"
| "CLIENT_NOT_FOUND"
| "CLIENT_ALREADY_LINKED" | "CLIENT_ALREADY_LINKED"
| "EMAIL_ALREADY_REGISTERED"; | "EMAIL_ALREADY_REGISTERED";
@@ -70,6 +72,25 @@ export async function createPortalInvitation(
const { db } = getSqliteConnection(); const { db } = getSqliteConnection();
const invitationId = db.transaction((tx) => { 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 const [linkedProfile] = tx
.select({ id: appProfiles.id }) .select({ id: appProfiles.id })
.from(appProfiles) .from(appProfiles)
@@ -333,6 +354,24 @@ export async function acceptPortalInvitation(input: {
}) })
.run(); .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 const accepted = tx
.update(portalInvitations) .update(portalInvitations)
.set({ status: "accepted", acceptedAt: now }) .set({ status: "accepted", acceptedAt: now })
@@ -417,6 +456,16 @@ export function setClientPortalAccess(
const { db } = getSqliteConnection(); const { db } = getSqliteConnection();
db.transaction((tx) => { 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 const [profile] = tx
.select({ authUserId: appProfiles.authUserId, email: appProfiles.email }) .select({ authUserId: appProfiles.authUserId, email: appProfiles.email })
.from(appProfiles) .from(appProfiles)
+24 -3
View File
@@ -1,13 +1,13 @@
import "server-only"; import "server-only";
import { eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { cache } from "react"; import { cache } from "react";
import { auth } from "@/server/auth/auth"; import { auth } from "@/server/auth/auth";
import type { UserRole } from "@/server/auth/types"; import type { UserRole } from "@/server/auth/types";
import { getSqliteConnection } from "@/server/db/client"; 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>>>; type BetterAuthSession = NonNullable<Awaited<ReturnType<typeof auth.api.getSession>>>;
@@ -108,5 +108,26 @@ export function getProfileByAuthUserId(authUserId: string): SessionContext["prof
.limit(1) .limit(1)
.all(); .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, "when": 1784205329112,
"tag": "0002_mighty_korg", "tag": "0002_mighty_korg",
"breakpoints": true "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, PortalInvitationStatus,
SetupStatus, SetupStatus,
UserRole, UserRole,
} from "@/server/auth/types"; } from "../../auth/types";
const nowMs = sql`(cast(unixepoch('subsecond') * 1000 as integer))`; 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 "./auth";
export * from "./domain";
export * from "./runtime"; 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);
}
+22
View File
@@ -0,0 +1,22 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"noEmit": false,
"isolatedModules": false,
"incremental": false,
"rootDir": ".",
"outDir": ".next/phase2-domain-smoke-dist"
},
"include": [
"scripts/phase2-domain-smoke.ts",
"server/auth/types.ts",
"server/db/schema/**/*.ts",
"server/domain/**/*.ts",
"server/repositories/**/*.ts",
"server/services/domain.ts"
],
"exclude": ["node_modules"]
}